diff options
55 files changed, 7405 insertions, 3832 deletions
diff --git a/.gitmodules b/.gitmodules index c6b083b..d955ecc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "wuffs-mirror-release-c"] - path = wuffs-mirror-release-c + path = submodules/wuffs-mirror-release-c url = https://github.com/google/wuffs-mirror-release-c +[submodule "liberty"] + path = submodules/liberty + url = https://git.janouch.name/p/liberty.git @@ -1,4 +1,4 @@ -Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +Copyright (c) 2021 - 2024, 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. diff --git a/README.adoc b/README.adoc index 218ba82..9ca9e65 100644 --- a/README.adoc +++ b/README.adoc @@ -2,7 +2,7 @@ fiv === 'fiv' is a slightly unconventional, general-purpose image browser and viewer -for Linux (that said, macOS and Windows ports are possible). +for Linux and Windows (macOS still has major issues). image::docs/fiv.webp["Screenshot of both the browser and the viewer"] @@ -13,7 +13,7 @@ Features photos, HEIC, AVIF, SVG, X11 cursors and TIFF, or whatever your gdk-pixbuf modules manage to load. - Employs high-performance file format libraries: Wuffs and libjpeg-turbo. - - Makes use of 30-bit X.org visuals, whenever it's possible and appropriate. + - Can make use of 30-bit X.org visuals, under certain conditions. - Has a notion of pages, and tries to load all included content within files. - Can keep the zoom and position when browsing, to help with comparing zoomed-in images. @@ -33,41 +33,60 @@ Not necessarily in this order. Packages -------- -Regular releases are sporadic. git master should be stable enough. You can get -a package with the latest development version from Archlinux's AUR. +Regular releases are sporadic. git master should be stable enough. +You can get a package with the latest development version using Arch Linux's +https://aur.archlinux.org/packages/fiv-git[AUR], +or as a https://git.janouch.name/p/nixexprs[Nix derivation]. Building and Running -------------------- -Build-only dependencies: Meson, pkg-config, asciidoctor or asciidoc + +Build-only dependencies: + Meson, pkg-config, asciidoctor or asciidoc (recommended but optional) + 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) + libturbojpeg, libwebp, libepoxy, librsvg-2.0 (for icons) + +Optional dependencies: lcms2, Little CMS fast float plugin, + LibRaw, librsvg-2.0, xcursor, libheif, libtiff, 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 + $ cd fiv + $ meson setup builddir $ cd builddir $ meson compile + $ meson devenv fiv -Considering the vast amount of dynamically-linked dependencies, do not attempt -direct installations via `ninja install`. To test the program: +The lossless JPEG cropper and reverse image search are intended to be invoked +from a file manager context menu. - $ meson devenv fiv +For proper integration, you will need to install the application. On Debian, +you can get a quick and dirty installation package for testing purposes using: -The lossless JPEG cropper is intended to be invoked from a context menu. + $ meson compile deb + # dpkg -i fiv-*.deb Windows ~~~~~~~ 'fiv' can be cross-compiled for Windows, provided that you install a bunch of -dependencies listed at the beginning of 'msys2-cross-configure.sh', -plus rsvg-convert from librsvg2, and icotool from icoutils. +dependencies listed at the beginning of 'msys2-configure.sh', +plus rsvg-convert from librsvg2, icotool from icoutils, and msitools ≥ 0.102. Beware that the build will take up about a gigabyte of disk space. - $ sh -e msys2-cross-configure.sh builddir - $ meson install -C builddir + $ sh -e msys2-configure.sh builddir + $ meson compile package -C builddir If everything succeeds, you will find a portable build of the application -in the 'builddir/package' subdirectory. Keep your expectations low. +in the 'builddir/package' subdirectory, and a very basic MSI installation +package in 'builddir'. + +Faster colour management +^^^^^^^^^^^^^^^^^^^^^^^^ +To get the Little CMS fast float plugin, you'll have to enter MSYS2 and +https://www.msys2.org/wiki/Creating-Packages/#re-building-a-package[rebuild] +_mingw-w64-lcms2_ with the following change: + + sed -i 's/meson setup /&-Dfastfloat=true /' PKGCONFIG Documentation ------------- diff --git a/docs/fiv.adoc b/docs/fiv.adoc index f7f992e..0188086 100644 --- a/docs/fiv.adoc +++ b/docs/fiv.adoc @@ -26,6 +26,19 @@ the _User Guide_. Options ------- +*--browse*:: + When an image is passed, start in browsing mode, and preselect that + image in its containing directory. This is used by *fiv*'s inode/directory + handler to implement the "Open Containing Folder" feature of certain + applications. + +*--collection*:: + Always put arguments in a virtual directory, even when only one is passed. + Implies *--browse*. + +*--help-all*:: + Show the full list of options, including those provided by GTK+. + *--invalidate-cache*:: Invalidate the wide thumbnail cache, removing thumbnails for files that can no longer be found. @@ -35,18 +48,11 @@ Options the list of MIME types within *fiv*'s desktop file when the list of GdkPixbuf loaders changes. -*--browse*:: - When an image is passed, start in browsing mode, and preselect that - image in its containing directory. This is used by *fiv*'s inode/directory - handler to implement the "Open Containing Folder" feature of certain - applications. - -*--thumbnail*=_SIZE_:: - Generate thumbnails for the first argument, in all sizes not exceeding - _SIZE_, and present the largest of them on the standard output - in an application-specific bitmap format. Available sizes follow directory - names in the _Thumbnail Managing Standard_. +*-V*, *--version*:: + Output version information and exit. +Internal options +~~~~~~~~~~~~~~~~ *--extract-thumbnail*:: Present any embedded thumbnail of the first argument on the standard output in an application-specific bitmap format. When both *--thumbnail* @@ -54,11 +60,16 @@ Options exiting early if successful. This is used to enhance responsivity of thumbnail procurement. -*-V*, *--version*:: - Output version information and exit. +*--thumbnail*=_SIZE_:: + Generate wide thumbnails for the first argument, in all sizes not exceeding + _SIZE_, and present the largest of them on the standard output + in an application-specific bitmap format. Available sizes follow directory + names in the _Thumbnail Managing Standard_. -*--help-all*:: - Show the full list of options, including those provided by GTK+. +*--thumbnail-for-search*=_SIZE_:: + Transform the first argument to a widely supported image file format, + and present it on the standard output. The image will be downscaled as + necessary so as to not exceed _SIZE_ (see *--thumbnail*). Reporting bugs -------------- diff --git a/docs/fiv.html b/docs/fiv.html index 5a9b918..a458882 100644 --- a/docs/fiv.html +++ b/docs/fiv.html @@ -17,9 +17,9 @@ q:lang(en):after { content: "’"; } <span id="author">Přemysl Eric Janouch</span><br> <span id="email"><a href="mailto:p@janouch.name">p@janouch.name</a></span><br> <span id="revnumber">version 0.0.0,</span> -<span id="revdate">2022-07-31</span> +<span id="revdate">2023-04-17</span> -<p class="figure"><img src="fiv.webp" alt="fiv's browser and viewer"> +<p class="figure"><img src="fiv.webp" alt="fiv in browser and viewer modes"> <h2>Introduction</h2> @@ -33,30 +33,31 @@ and page up/down buttons in mind, such as SteelSeries Sensei series. Ozone Neon series may also be mapped this way. Your experience may be degraded with other kinds of devices. -<p>Controls should generally be accessible through the keyboard. Pressing Ctrl+? -will give you a convenient overview of all shortcuts. In addition to these, -remember that you may often use Ctrl+Tab and F6 to navigate to different groups -of widgets. +<p>Controls should generally be accessible through the keyboard. Pressing +<kbd>Ctrl</kbd> + <kbd>?</kbd> will give you a convenient overview +of all shortcuts. In addition to these, remember that you may often use +<kbd>Ctrl</kbd> + <kbd>Tab</kbd> and <kbd>F6</kbd> to navigate to +different groups of widgets. <h2>Browser</h2> <p><i>fiv</i> normally starts in a file browser view. On the left side of the window, you'll find your GTK+ bookmarks, mounted locations as recognized by -GVfs, an item for entering arbitrary filesystem paths or URIs, view controls, -and finally breadcrumbs leading to the currently opened directory, as well as -its descendants. +GVfs, an item for entering arbitrary filesystem paths or URIs, and finally +breadcrumbs leading to the currently opened directory, as well as descendants +of it. At the top, there is a toolbar with view controls. -<p>You can open items in a new window either by middle clicking on them either -directly, or with the Ctrl key pressed down. Right clicking the directory view -offers a context menu for opening files, or even the directory itself, -in a different application. +<p>You can open items in a new window either by middle clicking on them, or with +the left mouse button while holding the <kbd>Ctrl</kbd> key. +Right clicking the directory view offers a context menu for opening files, +or even the directory itself, in a different application. <h2>Viewer</h2> <p>The image viewer may be both entered (so long as you have a file selected) -and exited using the Enter key. This way you may easily switch between the two -modes. When using the mouse, the forwards and backwards buttons will fulfill -the same function. +and exited using the <kbd>Enter</kbd> key. This way you may easily switch +between the two modes. When using the mouse, the forwards and backwards buttons +will fulfill the same function. <p>Double clicking the image switches full-screen view, and the mouse wheel adjusts the zoom level. @@ -94,14 +95,8 @@ rm -rf ~/.cache/thumbnails/wide-* <h2>Configuration</h2> -<p>The few configuration options <i>fiv</i> has can be adjusted using -<i>dconf-editor</i>, which can be launched in the appropriate location from -within the application by pressing Ctrl+,. For command line usage, there is -the <i>gsettings</i> utility: - -<pre> -gsettings list-recursively name.janouch.fiv -</pre> +<p>To adjust the few configuration options of <i>fiv</i>, +press <kbd>Ctrl</kbd> + <kbd>,</kbd> to open <i>Preferences</i>. <p>To make your changes take effect, restart <i>fiv</i>. diff --git a/docs/fiv.webp b/docs/fiv.webp Binary files differindex ef47681..249afe1 100644 --- a/docs/fiv.webp +++ b/docs/fiv.webp diff --git a/docs/stylesheet.css b/docs/stylesheet.css index b4b5b2d..daf49f9 100644 --- a/docs/stylesheet.css +++ b/docs/stylesheet.css @@ -5,4 +5,5 @@ h2 { padding-top: .67em; border-top: 1px solid silver; } p { line-height: 1.5; } .figure { text-align: center; } img { max-width: 100%; } q { font-style: normal; } .details { border-bottom: 1px solid silver; } .details br { display: none; } .details br + span:before { content: " — "; } -pre { padding: 0 1em; } +pre { padding: 0 1em; } kbd { border: solid #ccc; border-radius: .25em; + border-width: 1px 2px 2px 1px; padding: 0 .25em; font-family: inherit; } diff --git a/fiv-browser.c b/fiv-browser.c index 192c3bc..c9963f4 100644 --- a/fiv-browser.c +++ b/fiv-browser.c @@ -1,7 +1,7 @@ // // fiv-browser.c: filesystem browsing widget // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2024, 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. @@ -17,9 +17,6 @@ #include "config.h" -#include <math.h> -#include <pixman.h> - #include <gtk/gtk.h> #ifdef GDK_WINDOWING_X11 #include <gdk/gdkx.h> @@ -27,11 +24,16 @@ #ifdef GDK_WINDOWING_QUARTZ #include <gdk/gdkquartz.h> #endif // GDK_WINDOWING_QUARTZ +#include <pixman.h> + +#include <math.h> +#include <stdlib.h> #include "fiv-browser.h" #include "fiv-collection.h" #include "fiv-context-menu.h" #include "fiv-io.h" +#include "fiv-io-model.h" #include "fiv-thumbnail.h" // --- Widget ------------------------------------------------------------------ @@ -45,9 +47,12 @@ // │ n │ ┊ glow border │ n ┊ // │ g ╰───────────────────╯ g ╰┄┄┄┄┄ // │ s p a c i n g +// │ l a b e l +// │ s p a c i n g // │ ╭┄┄┄┄┄┄┄┄┄┄┄┄╮ ╭┄┄┄┄┄┄┄┄┄┄┄┄ // // The glow is actually a glowing margin, the border is rendered in two parts. +// When labels are hidden, the surrounding spacing is collapsed. // typedef struct entry Entry; @@ -72,8 +77,10 @@ struct _FivBrowser { int item_height; ///< Thumbnail height in pixels int item_spacing; ///< Space between items in pixels + gboolean show_labels; ///< Show labels underneath items + FivIoModel *model; ///< Filesystem model - GArray *entries; ///< []Entry + GPtrArray *entries; ///< []*Entry GArray *layouted_rows; ///< []Row const Entry *selected; ///< Selected entry or NULL @@ -85,7 +92,8 @@ struct _FivBrowser { Thumbnailer *thumbnailers; ///< Parallelized thumbnailers size_t thumbnailers_len; ///< Thumbnailers array size - GQueue thumbnailers_queue; ///< Queued up Entry pointers + GQueue thumbnailers_queue_1; ///< Queued up Entry pointers, hi-prio + GQueue thumbnailers_queue_2; ///< Queued up Entry pointers, lo-prio GdkCursor *pointer; ///< Cached pointer cursor cairo_pattern_t *glow; ///< CAIRO_FORMAT_A8 mask for corners @@ -100,29 +108,40 @@ struct _FivBrowser { /// The "last modified" timestamp of source images for thumbnails. static cairo_user_data_key_t fiv_browser_key_mtime_msec; +/// The original file size of source images for thumbnails. +static cairo_user_data_key_t fiv_browser_key_filesize; struct entry { - gchar *uri; ///< GIO URI - gchar *target_uri; ///< GIO URI for any target - gint64 mtime_msec; ///< Modification time in milliseconds + FivIoModelEntry *e; ///< Reference to model entry cairo_surface_t *thumbnail; ///< Prescaled thumbnail GIcon *icon; ///< If no thumbnail, use this icon + + gboolean removed; ///< Model announced removal }; +static Entry * +entry_new(FivIoModelEntry *e) +{ + Entry *self = g_slice_alloc0(sizeof *self); + self->e = e; + return self; +} + static void -entry_free(Entry *self) +entry_destroy(Entry *self) { - g_free(self->uri); - g_free(self->target_uri); + fiv_io_model_entry_unref(self->e); g_clear_pointer(&self->thumbnail, cairo_surface_destroy); g_clear_object(&self->icon); + g_slice_free1(sizeof *self, self); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - struct item { const Entry *entry; - int x_offset; ///< Offset within the row + PangoLayout *label; ///< Label + int x_offset; ///< X offset within the row }; struct row { @@ -135,11 +154,34 @@ struct row { static void row_free(Row *self) { + for (gsize i = 0; i < self->len; i++) + g_clear_object(&self->items[i].label); g_free(self->items); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +static double +row_subheight(const FivBrowser *self, const Row *row) +{ + if (!self->show_labels) + return 0; + + // If we didn't ellipsize labels, this should be made to account + // for vertical centering as well. + int tallest_label = 0; + for (gsize i = 0; i < row->len; i++) { + PangoRectangle ink = {}, logical = {}; + pango_layout_get_extents(row->items[i].label, &ink, &logical); + + int height = (logical.y + logical.height) / PANGO_SCALE; + if (tallest_label < height) + tallest_label = height; + } + + return self->item_spacing + tallest_label; +} + static void append_row(FivBrowser *self, int *y, int x, GArray *items_array) { @@ -154,6 +196,7 @@ append_row(FivBrowser *self, int *y, int x, GArray *items_array) // Not trying to pack them vertically, but this would be the place to do it. *y += self->item_height; *y += self->item_border_y; + *y += row_subheight(self, &row); } static int @@ -173,7 +216,7 @@ relayout(FivBrowser *self, int width) GArray *items = g_array_new(TRUE, TRUE, sizeof(Item)); int x = 0, y = padding.top; for (guint i = 0; i < self->entries->len; i++) { - const Entry *entry = &g_array_index(self->entries, Entry, i); + const Entry *entry = self->entries->pdata[i]; if (!entry->thumbnail) continue; @@ -189,8 +232,29 @@ relayout(FivBrowser *self, int width) x = 0; } - g_array_append_val(items, - ((Item) {.entry = entry, .x_offset = x + self->item_border_x})); + PangoLayout *label = NULL; + if (self->show_labels) { + label = gtk_widget_create_pango_layout( + widget, entry->e->display_name); + pango_layout_set_width( + label, (width - 2 * self->glow_w) * PANGO_SCALE); + pango_layout_set_alignment(label, PANGO_ALIGN_CENTER); + pango_layout_set_wrap(label, PANGO_WRAP_WORD_CHAR); + pango_layout_set_ellipsize(label, PANGO_ELLIPSIZE_END); + +#if PANGO_VERSION_CHECK(1, 44, 0) + PangoAttrList *attrs = pango_attr_list_new(); + pango_attr_list_insert(attrs, pango_attr_insert_hyphens_new(FALSE)); + pango_layout_set_attributes(label, attrs); + pango_attr_list_unref (attrs); +#endif + } + + g_array_append_val(items, ((Item) { + .entry = entry, + .label = label, + .x_offset = x + self->item_border_x, + })); x += width; if (max_width < width) @@ -212,14 +276,13 @@ relayout(FivBrowser *self, int width) gtk_adjustment_set_page_size(self->hadjustment, width); } if (self->vadjustment) { + int height = gtk_widget_get_allocated_height(widget); gtk_adjustment_set_lower(self->vadjustment, 0); - gtk_adjustment_set_upper(self->vadjustment, total_height); + gtk_adjustment_set_upper(self->vadjustment, MAX(height, total_height)); gtk_adjustment_set_step_increment(self->vadjustment, self->item_height + self->item_spacing + 2 * self->item_border_y); - gtk_adjustment_set_page_increment( - self->vadjustment, gtk_widget_get_allocated_height(widget) * 0.9); - gtk_adjustment_set_page_size( - self->vadjustment, gtk_widget_get_allocated_height(widget)); + gtk_adjustment_set_page_increment(self->vadjustment, height * 0.9); + gtk_adjustment_set_page_size(self->vadjustment, height); } return total_height; } @@ -342,7 +405,7 @@ draw_row(FivBrowser *self, cairo_t *cr, const Row *row) // Performance optimization--specifically targeting the checkerboard. if (cairo_image_surface_get_format(item->entry->thumbnail) != - CAIRO_FORMAT_RGB24) { + CAIRO_FORMAT_RGB24 || item->entry->removed) { gtk_render_background(style, cr, border.left, border.top, extents.width, extents.height); } @@ -358,12 +421,49 @@ draw_row(FivBrowser *self, cairo_t *cr, const Row *row) cairo_mask_surface( cr, item->entry->thumbnail, border.left, border.top); } else { + // Distinguish removed items by rendering them only faintly. + if (item->entry->removed) + cairo_push_group(cr); + cairo_set_source_surface( cr, item->entry->thumbnail, border.left, border.top); cairo_paint(cr); - // Here, we could consider multiplying + // Here, we could also consider multiplying // the whole rectangle with the selection color. + if (item->entry->removed) { + cairo_pop_group_to_source(cr); + cairo_paint_with_alpha(cr, 0.25); + } + } + + // This rendition is about the best I could come up with. + // It might be possible to use more such emblems with entries, + // though they would deserve some kind of a blur-glow. + if (item->entry->removed) { + int size = 32; + cairo_surface_t *cross = gtk_icon_theme_load_surface( + gtk_icon_theme_get_default(), "cross-large-symbolic", + size, gtk_widget_get_scale_factor(GTK_WIDGET(self)), + gtk_widget_get_window(GTK_WIDGET(self)), + GTK_ICON_LOOKUP_FORCE_SYMBOLIC, NULL); + if (cross) { + cairo_set_source_rgb(cr, 1, 0, 0); + cairo_mask_surface(cr, cross, + border.left + extents.width - size - size / 4, + border.top + extents.height - size - size / 4); + cairo_surface_destroy(cross); + } + } + + if (self->show_labels) { + gtk_style_context_save(style); + gtk_style_context_add_class(style, "label"); + gtk_render_layout(style, cr, -border.left, + border.top + extents.height + self->item_border_y + + self->item_spacing, + item->label); + gtk_style_context_restore(style); } cairo_restore(cr); @@ -440,51 +540,81 @@ rescale_thumbnail(cairo_surface_t *thumbnail, double row_height) return scaled; } -static char * +static const char * entry_system_wide_uri(const Entry *self) { // "recent" and "trash", e.g., also have "standard::target-uri" set, // but we'd like to avoid saving their thumbnails. - if (self->target_uri && fiv_collection_uri_matches(self->uri)) - return self->target_uri; + if (self->e->target_uri && fiv_collection_uri_matches(self->e->uri)) + return self->e->target_uri; - return self->uri; + return self->e->uri; } static void -entry_add_thumbnail(gpointer data, gpointer user_data) +entry_set_surface_user_data(const Entry *self) { - Entry *self = data; - g_clear_object(&self->icon); - g_clear_pointer(&self->thumbnail, cairo_surface_destroy); + // This choice of mtime favours unnecessary thumbnail reloading + // over retaining stale data (consider both calling functions). + cairo_surface_set_user_data(self->thumbnail, + &fiv_browser_key_mtime_msec, (void *) (intptr_t) self->e->mtime_msec, + NULL); + cairo_surface_set_user_data(self->thumbnail, + &fiv_browser_key_filesize, (void *) (uintptr_t) self->e->filesize, + NULL); +} - FivBrowser *browser = FIV_BROWSER(user_data); +static cairo_surface_t * +entry_lookup_thumbnail(Entry *self, FivBrowser *browser) +{ cairo_surface_t *cached = - g_hash_table_lookup(browser->thumbnail_cache, self->uri); + g_hash_table_lookup(browser->thumbnail_cache, self->e->uri); if (cached && - (intptr_t) cairo_surface_get_user_data( - cached, &fiv_browser_key_mtime_msec) == self->mtime_msec) { - self->thumbnail = cairo_surface_reference(cached); + (intptr_t) cairo_surface_get_user_data(cached, + &fiv_browser_key_mtime_msec) == (intptr_t) self->e->mtime_msec && + (uintptr_t) cairo_surface_get_user_data(cached, + &fiv_browser_key_filesize) == (uintptr_t) self->e->filesize) { // TODO(p): If this hit is low-quality, see if a high-quality thumbnail // hasn't been produced without our knowledge (avoid launching a minion // unnecessarily; we might also shift the concern there). - } else { - cairo_surface_t *found = fiv_thumbnail_lookup( - entry_system_wide_uri(self), self->mtime_msec, browser->item_size); - self->thumbnail = rescale_thumbnail(found, browser->item_height); + return cairo_surface_reference(cached); + } + + cairo_surface_t *found = fiv_thumbnail_lookup( + entry_system_wide_uri(self), self->e->mtime_msec, self->e->filesize, + browser->item_size); + return rescale_thumbnail(found, browser->item_height); +} + +static void +entry_add_thumbnail(gpointer data, gpointer user_data) +{ + Entry *self = data; + FivBrowser *browser = FIV_BROWSER(user_data); + if (self->removed) { + // Keep whatever size of thumbnail we had at the time up until reload. + // g_file_query_info() fails for removed files, so keep the icon, too. + if (self->icon) { + g_clear_pointer(&self->thumbnail, cairo_surface_destroy); + } else { + self->thumbnail = + rescale_thumbnail(self->thumbnail, browser->item_height); + } + return; } - if (self->thumbnail) { - // This choice of mtime favours unnecessary thumbnail reloading. - cairo_surface_set_user_data(self->thumbnail, - &fiv_browser_key_mtime_msec, (void *) (intptr_t) self->mtime_msec, - NULL); + g_clear_object(&self->icon); + g_clear_pointer(&self->thumbnail, cairo_surface_destroy); + + if ((self->thumbnail = entry_lookup_thumbnail(self, browser))) { + // Yes, this is a pointless action in case it's been found in the cache. + entry_set_surface_user_data(self); return; } // Fall back to symbolic icons, though there's only so much we can do // in parallel--GTK+ isn't thread-safe. - GFile *file = g_file_new_for_uri(self->uri); + GFile *file = g_file_new_for_uri(self->e->uri); GFileInfo *info = g_file_query_info(file, G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_SYMBOLIC_ICON, @@ -513,11 +643,15 @@ materialize_icon(FivBrowser *self, Entry *entry) // of using GLib to look up icons for us, derive a list from a guessed // MIME type, with "-symbolic" prefixes and fallbacks, // and use gtk_icon_theme_choose_icon() instead. - // TODO(p): Make sure we have /some/ icon for every entry. // TODO(p): We might want to populate these on an as-needed basis. - GtkIconInfo *icon_info = gtk_icon_theme_lookup_by_gicon( - gtk_icon_theme_get_default(), entry->icon, self->item_height / 2, - GTK_ICON_LOOKUP_FORCE_SYMBOLIC); + GtkIconTheme *theme = gtk_icon_theme_get_default(); + GtkIconInfo *icon_info = gtk_icon_theme_lookup_by_gicon(theme, entry->icon, + self->item_height / 2, GTK_ICON_LOOKUP_FORCE_SYMBOLIC); + if (!icon_info) { + // This icon is included within GTK+. + icon_info = gtk_icon_theme_lookup_icon(theme, "text-x-generic", + self->item_height / 2, GTK_ICON_LOOKUP_FORCE_SYMBOLIC); + } if (!icon_info) return; @@ -547,26 +681,38 @@ materialize_icon(FivBrowser *self, Entry *entry) } static void +reload_one_thumbnail_finish(FivBrowser *self, Entry *entry) +{ + if (!entry->removed && entry->thumbnail) { + g_hash_table_insert(self->thumbnail_cache, g_strdup(entry->e->uri), + cairo_surface_reference(entry->thumbnail)); + } + + materialize_icon(self, entry); +} + +static void +reload_one_thumbnail(FivBrowser *self, Entry *entry) +{ + entry_add_thumbnail(entry, self); + reload_one_thumbnail_finish(self, entry); + + gtk_widget_queue_resize(GTK_WIDGET(self)); +} + +static void reload_thumbnails(FivBrowser *self) { GThreadPool *pool = g_thread_pool_new( entry_add_thumbnail, self, g_get_num_processors(), FALSE, NULL); for (guint i = 0; i < self->entries->len; i++) - g_thread_pool_push(pool, &g_array_index(self->entries, Entry, i), NULL); + g_thread_pool_push(pool, self->entries->pdata[i], NULL); g_thread_pool_free(pool, FALSE, TRUE); // Once a URI disappears from the model, its thumbnail is forgotten. g_hash_table_remove_all(self->thumbnail_cache); - - for (guint i = 0; i < self->entries->len; i++) { - Entry *entry = &g_array_index(self->entries, Entry, i); - if (entry->thumbnail) { - g_hash_table_insert(self->thumbnail_cache, g_strdup(entry->uri), - cairo_surface_reference(entry->thumbnail)); - } - - materialize_icon(self, entry); - } + for (guint i = 0; i < self->entries->len; i++) + reload_one_thumbnail_finish(self, self->entries->pdata[i]); gtk_widget_queue_resize(GTK_WIDGET(self)); } @@ -597,15 +743,11 @@ thumbnailer_reprocess_entry(FivBrowser *self, GBytes *output, Entry *entry) if ((flags & FIV_IO_SERIALIZE_LOW_QUALITY)) { cairo_surface_set_user_data(entry->thumbnail, &fiv_thumbnail_key_lq, (void *) (intptr_t) 1, NULL); - g_queue_push_tail(&self->thumbnailers_queue, entry); + g_queue_push_tail(&self->thumbnailers_queue_2, entry); } - // This choice of mtime favours unnecessary thumbnail reloading - // over retaining stale data. - cairo_surface_set_user_data(entry->thumbnail, - &fiv_browser_key_mtime_msec, (void *) (intptr_t) entry->mtime_msec, - NULL); - g_hash_table_insert(self->thumbnail_cache, g_strdup(entry->uri), + entry_set_surface_user_data(entry); + g_hash_table_insert(self->thumbnail_cache, g_strdup(entry->e->uri), cairo_surface_reference(entry->thumbnail)); } @@ -655,13 +797,21 @@ on_thumbnailer_ready(GObject *object, GAsyncResult *res, gpointer user_data) thumbnailer_next(t); } +// TODO(p): Try to keep the minions alive (stdout will be a problem). static gboolean thumbnailer_next(Thumbnailer *t) { - // TODO(p): Try to keep the minions alive (stdout will be a problem). + // Already have something to do, not a failure. + if (t->target) + return TRUE; + + // They could have been removed via post-reload changes in the model. FivBrowser *self = t->self; - if (!(t->target = g_queue_pop_head(&self->thumbnailers_queue))) - return FALSE; + do { + if (!(t->target = g_queue_pop_head(&self->thumbnailers_queue_1)) && + !(t->target = g_queue_pop_head(&self->thumbnailers_queue_2))) + return FALSE; + } while (t->target->removed); // Case analysis: // - We haven't found any thumbnail for the entry at all @@ -698,7 +848,8 @@ thumbnailer_next(Thumbnailer *t) static void thumbnailers_abort(FivBrowser *self) { - g_queue_clear(&self->thumbnailers_queue); + g_queue_clear(&self->thumbnailers_queue_1); + g_queue_clear(&self->thumbnailers_queue_2); for (size_t i = 0; i < self->thumbnailers_len; i++) { Thumbnailer *t = self->thumbnailers + i; @@ -714,32 +865,35 @@ thumbnailers_abort(FivBrowser *self) } static void -thumbnailers_start(FivBrowser *self) +thumbnailers_enqueue(FivBrowser *self, Entry *entry) { - thumbnailers_abort(self); - if (!self->model) - return; - - GQueue lq = G_QUEUE_INIT; - for (guint i = 0; i < self->entries->len; i++) { - Entry *entry = &g_array_index(self->entries, Entry, i); + if (!entry->removed) { if (entry->icon) - g_queue_push_tail(&self->thumbnailers_queue, entry); + g_queue_push_tail(&self->thumbnailers_queue_1, entry); else if (cairo_surface_get_user_data( entry->thumbnail, &fiv_thumbnail_key_lq)) - g_queue_push_tail(&lq, entry); - } - while (!g_queue_is_empty(&lq)) { - g_queue_push_tail_link( - &self->thumbnailers_queue, g_queue_pop_head_link(&lq)); + g_queue_push_tail(&self->thumbnailers_queue_2, entry); } +} +static void +thumbnailers_deploy(FivBrowser *self) +{ for (size_t i = 0; i < self->thumbnailers_len; i++) { if (!thumbnailer_next(self->thumbnailers + i)) break; } } +static void +thumbnailers_restart(FivBrowser *self) +{ + thumbnailers_abort(self); + for (guint i = 0; i < self->entries->len; i++) + thumbnailers_enqueue(self, self->entries->pdata[i]); + thumbnailers_deploy(self); +} + // --- Boilerplate ------------------------------------------------------------- G_DEFINE_TYPE_EXTENDED(FivBrowser, fiv_browser, GTK_TYPE_WIDGET, 0, @@ -747,6 +901,7 @@ G_DEFINE_TYPE_EXTENDED(FivBrowser, fiv_browser, GTK_TYPE_WIDGET, 0, enum { PROP_THUMBNAIL_SIZE = 1, + PROP_SHOW_LABELS, N_PROPERTIES, // These are overriden, we do not register them. @@ -799,7 +954,7 @@ fiv_browser_finalize(GObject *gobject) { FivBrowser *self = FIV_BROWSER(gobject); thumbnailers_abort(self); - g_array_free(self->entries, TRUE); + g_ptr_array_free(self->entries, TRUE); g_array_free(self->layouted_rows, TRUE); if (self->model) { g_signal_handlers_disconnect_by_data(self->model, self); @@ -827,6 +982,9 @@ fiv_browser_get_property( case PROP_THUMBNAIL_SIZE: g_value_set_enum(value, self->item_size); break; + case PROP_SHOW_LABELS: + g_value_set_boolean(value, self->show_labels); + break; case PROP_HADJUSTMENT: g_value_set_object(value, self->hadjustment); break; @@ -856,7 +1014,7 @@ set_item_size(FivBrowser *self, FivThumbnailSize size) g_hash_table_remove_all(self->thumbnail_cache); reload_thumbnails(self); - thumbnailers_start(self); + thumbnailers_restart(self); g_object_notify_by_pspec( G_OBJECT(self), browser_properties[PROP_THUMBNAIL_SIZE]); @@ -872,6 +1030,13 @@ fiv_browser_set_property( case PROP_THUMBNAIL_SIZE: set_item_size(self, g_value_get_enum(value)); break; + case PROP_SHOW_LABELS: + if (self->show_labels != g_value_get_boolean(value)) { + self->show_labels = g_value_get_boolean(value); + gtk_widget_queue_resize(GTK_WIDGET(self)); + g_object_notify_by_pspec(object, pspec); + } + break; case PROP_HADJUSTMENT: if (replace_adjustment( self, &self->hadjustment, g_value_get_object(value))) @@ -1042,7 +1207,7 @@ fiv_browser_draw(GtkWidget *widget, cairo_t *cr) static gboolean open_entry(GtkWidget *self, const Entry *entry, gboolean new_window) { - GFile *location = g_file_new_for_uri(entry->uri); + GFile *location = g_file_new_for_uri(entry->e->uri); g_signal_emit(self, browser_signals[ITEM_ACTIVATED], 0, location, new_window ? GTK_PLACES_OPEN_NEW_WINDOW : GTK_PLACES_OPEN_NORMAL); g_object_unref(location); @@ -1052,7 +1217,9 @@ open_entry(GtkWidget *self, const Entry *entry, gboolean new_window) static void show_context_menu(GtkWidget *widget, GFile *file) { - gtk_menu_popup_at_pointer(fiv_context_menu_new(widget, file), NULL); + GtkMenu *menu = fiv_context_menu_new(widget, file); + if (menu) + gtk_menu_popup_at_pointer(menu, NULL); } static void @@ -1112,7 +1279,7 @@ fiv_browser_button_press_event(GtkWidget *widget, GdkEventButton *event) // no matter what its new location is. gdk_window_set_cursor(gtk_widget_get_window(widget), NULL); - GFile *file = g_file_new_for_uri(entry->uri); + GFile *file = g_file_new_for_uri(entry->e->uri); show_context_menu(widget, file); g_object_unref(file); return GDK_EVENT_STOP; @@ -1256,8 +1423,8 @@ fiv_browser_drag_data_get(GtkWidget *widget, { FivBrowser *self = FIV_BROWSER(widget); if (self->selected) { - (void) gtk_selection_data_set_uris( - data, (gchar *[]) {entry_system_wide_uri(self->selected), NULL}); + (void) gtk_selection_data_set_uris(data, (gchar *[]) + {(gchar *) entry_system_wide_uri(self->selected), NULL}); } } @@ -1283,12 +1450,13 @@ scroll_to_row(FivBrowser *self, const Row *row) double y1 = gtk_adjustment_get_value(self->vadjustment); double ph = gtk_adjustment_get_page_size(self->vadjustment); + double sh = self->item_border_y + row_subheight(self, row); if (row->y_offset < y1) { gtk_adjustment_set_value( self->vadjustment, row->y_offset - self->item_border_y); - } else if (row->y_offset + self->item_height > y1 + ph) { - gtk_adjustment_set_value(self->vadjustment, - row->y_offset - ph + self->item_height + self->item_border_y); + } else if (row->y_offset + self->item_height + sh > y1 + ph) { + gtk_adjustment_set_value( + self->vadjustment, row->y_offset - ph + self->item_height + sh); } } @@ -1404,6 +1572,14 @@ fiv_browser_key_press_event(GtkWidget *widget, GdkEventKey *event) switch ((event->state & gtk_accelerator_get_default_mod_mask())) { case 0: switch (event->keyval) { + case GDK_KEY_Delete: + if (self->selected) { + GtkWindow *window = GTK_WINDOW(gtk_widget_get_toplevel(widget)); + GFile *file = g_file_new_for_uri(self->selected->e->uri); + fiv_context_menu_remove(window, file); + g_object_unref(file); + } + return GDK_EVENT_STOP; case GDK_KEY_Return: if (self->selected) return open_entry(widget, self->selected, FALSE); @@ -1433,7 +1609,7 @@ fiv_browser_key_press_event(GtkWidget *widget, GdkEventKey *event) case GDK_KEY_Return: if (self->selected) { GtkWindow *window = GTK_WINDOW(gtk_widget_get_toplevel(widget)); - fiv_context_menu_information(window, self->selected->uri); + fiv_context_menu_information(window, self->selected->e->uri); } return GDK_EVENT_STOP; } @@ -1459,20 +1635,16 @@ fiv_browser_query_tooltip(GtkWidget *widget, gint x, gint y, G_GNUC_UNUSED gboolean keyboard_tooltip, GtkTooltip *tooltip) { FivBrowser *self = FIV_BROWSER(widget); - const Entry *entry = entry_at(self, x, y); - if (!entry) + + // TODO(p): Consider getting rid of tooltips altogether. + if (self->show_labels) return FALSE; - GFile *file = g_file_new_for_uri(entry->uri); - GFileInfo *info = - g_file_query_info(file, G_FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, - G_FILE_QUERY_INFO_NONE, NULL, NULL); - g_object_unref(file); - if (!info) + const Entry *entry = entry_at(self, x, y); + if (!entry) return FALSE; - gtk_tooltip_set_text(tooltip, g_file_info_get_display_name(info)); - g_object_unref(info); + gtk_tooltip_set_text(tooltip, entry->e->display_name); return TRUE; } @@ -1486,7 +1658,7 @@ fiv_browser_popup_menu(GtkWidget *widget) GFile *file = NULL; GdkRectangle rect = {}; if (self->selected) { - file = g_file_new_for_uri(self->selected->uri); + file = g_file_new_for_uri(self->selected->e->uri); rect = entry_rect(self, self->selected); rect.x += rect.width / 2; rect.y += rect.height / 2; @@ -1524,7 +1696,7 @@ on_long_press(GtkGestureLongPress *lp, gdouble x, gdouble y, gpointer user_data) // It might also be possible to have long-press just select items, // and show some kind of toolbar with available actions. - GFile *file = g_file_new_for_uri(entry->uri); + GFile *file = g_file_new_for_uri(entry->e->uri); gtk_menu_popup_at_rect(fiv_context_menu_new(widget, file), window, &(GdkRectangle) {.x = x, .y = y}, GDK_GRAVITY_NORTH_WEST, GDK_GRAVITY_NORTH_WEST, event); @@ -1553,6 +1725,13 @@ fiv_browser_style_updated(GtkWidget *widget) gtk_style_context_add_class(style, "item"); gtk_style_context_get_margin(style, GTK_STATE_FLAG_NORMAL, &margin); gtk_style_context_get_border(style, GTK_STATE_FLAG_NORMAL, &border); + // XXX: Right now, specifying custom fonts within our CSS pseudo-regions + // has no effect, so it might be appropriate to also add .label/.symbolic + // classes here, remember the resulting GTK_STYLE_PROPERTY_FONT, + // and apply them in relayout() with pango_layout_set_font_description(). + // There is virtually nothing to be gained from this flexibility, though. + // XXX: We should also invoke relayout() here, because different states + // might theoretically use different fonts. gtk_style_context_restore(style); self->glow_w = (margin.left + margin.right) / 2; @@ -1634,6 +1813,9 @@ fiv_browser_class_init(FivBrowserClass *klass) "thumbnail-size", "Thumbnail size", "The thumbnail height to use", FIV_TYPE_THUMBNAIL_SIZE, FIV_THUMBNAIL_SIZE_NORMAL, G_PARAM_READWRITE); + browser_properties[PROP_SHOW_LABELS] = g_param_spec_boolean( + "show-labels", "Show labels", "Whether to show filename labels", + FALSE, G_PARAM_READWRITE); g_object_class_install_properties( object_class, N_PROPERTIES, browser_properties); @@ -1686,8 +1868,8 @@ fiv_browser_init(FivBrowser *self) gtk_widget_set_can_focus(GTK_WIDGET(self), TRUE); gtk_widget_set_has_tooltip(GTK_WIDGET(self), TRUE); - self->entries = g_array_new(FALSE, TRUE, sizeof(Entry)); - g_array_set_clear_func(self->entries, (GDestroyNotify) entry_free); + self->entries = + g_ptr_array_new_with_free_func((GDestroyNotify) entry_destroy); self->layouted_rows = g_array_new(FALSE, TRUE, sizeof(Row)); g_array_set_clear_func(self->layouted_rows, (GDestroyNotify) row_free); abort_button_tracking(self); @@ -1700,9 +1882,11 @@ fiv_browser_init(FivBrowser *self) g_malloc0_n(self->thumbnailers_len, sizeof *self->thumbnailers); for (size_t i = 0; i < self->thumbnailers_len; i++) self->thumbnailers[i].self = self; - g_queue_init(&self->thumbnailers_queue); + g_queue_init(&self->thumbnailers_queue_1); + g_queue_init(&self->thumbnailers_queue_2); set_item_size(self, FIV_THUMBNAIL_SIZE_NORMAL); + self->show_labels = FALSE; self->glow_padded = cairo_pattern_create_rgba(0, 0, 0, 0); self->glow = cairo_pattern_create_rgba(0, 0, 0, 0); @@ -1721,35 +1905,80 @@ fiv_browser_init(FivBrowser *self) // --- Public interface -------------------------------------------------------- -// TODO(p): Later implement any arguments of this FivIoModel signal. static void -on_model_files_changed(FivIoModel *model, FivBrowser *self) +on_model_reloaded(FivIoModel *model, FivBrowser *self) { g_return_if_fail(model == self->model); gchar *selected_uri = NULL; if (self->selected) - selected_uri = g_strdup(self->selected->uri); + selected_uri = g_strdup(self->selected->e->uri); thumbnailers_abort(self); - g_array_set_size(self->entries, 0); g_array_set_size(self->layouted_rows, 0); + g_ptr_array_set_size(self->entries, 0); gsize len = 0; - const FivIoModelEntry *files = fiv_io_model_get_files(self->model, &len); + FivIoModelEntry *const *files = fiv_io_model_get_files(self->model, &len); for (gsize i = 0; i < len; i++) { - Entry e = {.thumbnail = NULL, - .uri = g_strdup(files[i].uri), - .target_uri = g_strdup(files[i].target_uri), - .mtime_msec = files[i].mtime_msec}; - g_array_append_val(self->entries, e); + g_ptr_array_add( + self->entries, entry_new(fiv_io_model_entry_ref(files[i]))); } fiv_browser_select(self, selected_uri); g_free(selected_uri); + // Restarting thumbnailers is critical, because they keep Entry pointers. reload_thumbnails(self); - thumbnailers_start(self); + thumbnailers_restart(self); +} + +static void +on_model_changed(FivIoModel *model, FivIoModelEntry *old, FivIoModelEntry *new, + FivBrowser *self) +{ + g_return_if_fail(model == self->model); + + // Add new entries to the end, so as to not disturb the layout. + if (!old) { + Entry *entry = entry_new(fiv_io_model_entry_ref(new)); + g_ptr_array_add(self->entries, entry); + + reload_one_thumbnail(self, entry); + thumbnailers_enqueue(self, entry); + thumbnailers_deploy(self); + return; + } + + Entry *found = NULL; + for (guint i = 0; i < self->entries->len; i++) { + Entry *entry = self->entries->pdata[i]; + if (entry->e == old) { + found = entry; + break; + } + } + if (!found) + return; + + // Rename entries in place, so as to not disturb the layout. + // XXX: This behaves differently from FivIoModel, and by extension fiv.c. + if (new) { + fiv_io_model_entry_unref(found->e); + found->e = fiv_io_model_entry_ref(new); + found->removed = FALSE; + + // TODO(p): If there is a URI mismatch, don't reload thumbnails, + // so that there's no jumping around. Or, a bit more properly, + // move the thumbnail cache entry to the new URI. + reload_one_thumbnail(self, found); + // TODO(p): Rather cancel the entry in any running thumbnailer, + // remove it from queues, and _enqueue() + _deploy(). + thumbnailers_restart(self); + } else { + found->removed = TRUE; + gtk_widget_queue_draw(GTK_WIDGET(self)); + } } GtkWidget * @@ -1760,9 +1989,11 @@ fiv_browser_new(FivIoModel *model) FivBrowser *self = g_object_new(FIV_TYPE_BROWSER, NULL); self->model = g_object_ref(model); - g_signal_connect( - self->model, "files-changed", G_CALLBACK(on_model_files_changed), self); - on_model_files_changed(self->model, self); + g_signal_connect(self->model, "reloaded", + G_CALLBACK(on_model_reloaded), self); + g_signal_connect(self->model, "files-changed", + G_CALLBACK(on_model_changed), self); + on_model_reloaded(self->model, self); return GTK_WIDGET(self); } @@ -1791,8 +2022,8 @@ fiv_browser_select(FivBrowser *self, const char *uri) return; for (guint i = 0; i < self->entries->len; i++) { - const Entry *entry = &g_array_index(self->entries, Entry, i); - if (!g_strcmp0(entry->uri, uri)) { + const Entry *entry = self->entries->pdata[i]; + if (!g_strcmp0(entry->e->uri, uri)) { self->selected = entry; scroll_to_selection(self); break; diff --git a/fiv-browser.h b/fiv-browser.h index 0a93721..701cb50 100644 --- a/fiv-browser.h +++ b/fiv-browser.h @@ -17,7 +17,7 @@ #pragma once -#include "fiv-io.h" +#include "fiv-io-model.h" #include <gtk/gtk.h> diff --git a/fiv-collection.c b/fiv-collection.c index 13548b9..6878898 100644 --- a/fiv-collection.c +++ b/fiv-collection.c @@ -528,12 +528,16 @@ fiv_collection_file_query_info(GFile *file, const char *attributes, g_file_info_set_name(info, basename); g_free(basename); - if ((name = g_file_info_get_display_name(info))) { + if (g_file_info_has_attribute( + info, G_FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME) && + (name = g_file_info_get_display_name(info))) { gchar *prefixed = get_prefixed_name(self, name); g_file_info_set_display_name(info, prefixed); g_free(prefixed); } - if ((name = g_file_info_get_edit_name(info))) { + if (g_file_info_has_attribute( + info, G_FILE_ATTRIBUTE_STANDARD_EDIT_NAME) && + (name = g_file_info_get_edit_name(info))) { gchar *prefixed = get_prefixed_name(self, name); g_file_info_set_edit_name(info, prefixed); g_free(prefixed); diff --git a/fiv-context-menu.c b/fiv-context-menu.c index b5dafc4..16460b6 100644 --- a/fiv-context-menu.c +++ b/fiv-context-menu.c @@ -1,7 +1,7 @@ // // fiv-context-menu.c: popup menu // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2024, 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. @@ -328,6 +328,17 @@ open_context_unref(gpointer data, G_GNUC_UNUSED GClosure *closure) } static void +show_error_dialog(GtkWindow *parent, GError *error) +{ + GtkWidget *dialog = + gtk_message_dialog_new(GTK_WINDOW(parent), 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 void open_context_launch(GtkWidget *widget, OpenContext *self) { GdkAppLaunchContext *context = @@ -342,8 +353,9 @@ open_context_launch(GtkWidget *widget, OpenContext *self) (void) g_app_info_set_as_last_used_for_type( self->app_info, self->content_type, NULL); } else { - g_warning("%s", error->message); - g_error_free(error); + GtkWindow *window = g_weak_ref_get(&self->window); + show_error_dialog(window, error); + g_clear_object(&window); } g_list_free(files); g_object_unref(context); @@ -396,6 +408,15 @@ on_chooser_activate(GtkMenuItem *item, gpointer user_data) GtkWidget *dialog = gtk_app_chooser_dialog_new_for_content_type(window, GTK_DIALOG_DESTROY_WITH_PARENT | GTK_DIALOG_MODAL, ctx->content_type); g_clear_object(&window); + +#if 0 + // This exists as a concept in mimeapps.list, but GNOME infuriatingly + // infers it from the last used application if missing. + gtk_app_chooser_widget_set_show_default( + GTK_APP_CHOOSER_WIDGET(gtk_app_chooser_dialog_get_widget( + GTK_APP_CHOOSER_DIALOG(dialog))), TRUE); +#endif + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_OK) { ctx->app_info = gtk_app_chooser_get_app_info(GTK_APP_CHOOSER(dialog)); open_context_launch(GTK_WIDGET(item), ctx); @@ -414,6 +435,24 @@ on_info_activate(G_GNUC_UNUSED GtkMenuItem *item, gpointer user_data) g_free(uri); } +void +fiv_context_menu_remove(GtkWindow *parent, GFile *file) +{ + // TODO(p): Use g_file_trash_async(), for which we need a task manager. + GError *error = NULL; + if (!g_file_trash(file, NULL, &error)) + show_error_dialog(parent, error); +} + +static void +on_trash_activate(G_GNUC_UNUSED GtkMenuItem *item, gpointer user_data) +{ + OpenContext *ctx = user_data; + GtkWindow *window = g_weak_ref_get(&ctx->window); + fiv_context_menu_remove(window, ctx->file); + g_clear_object(&window); +} + static gboolean destroy_widget_idle_source_func(GtkWidget *widget) { @@ -494,11 +533,21 @@ fiv_context_menu_new(GtkWidget *widget, GFile *file) ctx, open_context_unref, 0); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item); + // TODO(p): Can we avoid using the "trash" string constant for this check? + if (!g_file_has_uri_scheme(file, "trash")) { + gtk_menu_shell_append( + GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); + + item = gtk_menu_item_new_with_mnemonic("Move to _Trash"); + g_signal_connect_data(item, "activate", G_CALLBACK(on_trash_activate), + g_rc_box_acquire(ctx), open_context_unref, 0); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item); + } if (type == G_FILE_TYPE_REGULAR) { gtk_menu_shell_append( GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - item = gtk_menu_item_new_with_mnemonic("_Information..."); + item = gtk_menu_item_new_with_mnemonic("_Information"); g_signal_connect_data(item, "activate", G_CALLBACK(on_info_activate), g_rc_box_acquire(ctx), open_context_unref, 0); gtk_menu_shell_append(GTK_MENU_SHELL(menu), item); diff --git a/fiv-context-menu.h b/fiv-context-menu.h index 8da58c1..34374aa 100644 --- a/fiv-context-menu.h +++ b/fiv-context-menu.h @@ -1,7 +1,7 @@ // // fiv-context-menu.h: popup menu // -// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2022 - 2024, 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. @@ -18,4 +18,5 @@ #include <gtk/gtk.h> void fiv_context_menu_information(GtkWindow *parent, const char *uri); +void fiv_context_menu_remove(GtkWindow *parent, GFile *file); GtkMenu *fiv_context_menu_new(GtkWidget *widget, GFile *file); diff --git a/fiv-io-cmm.c b/fiv-io-cmm.c new file mode 100644 index 0000000..b131acf --- /dev/null +++ b/fiv-io-cmm.c @@ -0,0 +1,462 @@ +// +// fiv-io-cmm.c: colour management +// +// Copyright (c) 2024, 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 "config.h" + +#include <glib.h> +#include <stdbool.h> + +#include "fiv-io.h" + +// Colour management must be handled before RGB conversions. +// TODO(p): Make it also possible to use Skia's skcms. +#ifdef HAVE_LCMS2 +#include <lcms2.h> +#endif // HAVE_LCMS2 +#ifdef HAVE_LCMS2_FAST_FLOAT +#include <lcms2_fast_float.h> +#endif // HAVE_LCMS2_FAST_FLOAT + +// --- CMM-independent transforms ---------------------------------------------- + +// CAIRO_STRIDE_ALIGNMENT is 4 bytes, so there will be no padding with +// ARGB/BGRA/XRGB/BGRX. +static void +trivial_cmyk_to_host_byte_order_argb(unsigned char *p, int len) +{ + // This CMYK handling has been seen in gdk-pixbuf/JPEG, GIMP/JPEG, skcms. + // It will typically produce horribly oversaturated results. + // Assume that all YCCK/CMYK JPEG files use inverted CMYK, as Photoshop + // does, see https://bugzilla.gnome.org/show_bug.cgi?id=618096 + while (len--) { + int c = p[0], m = p[1], y = p[2], k = p[3]; +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + p[0] = k * y / 255; + p[1] = k * m / 255; + p[2] = k * c / 255; + p[3] = 255; +#else + p[3] = k * y / 255; + p[2] = k * m / 255; + p[1] = k * c / 255; + p[0] = 255; +#endif + p += 4; + } +} + +// From libwebp, verified to exactly match [x * a / 255]. +#define PREMULTIPLY8(a, x) (((uint32_t) (x) * (uint32_t) (a) * 32897U) >> 23) + +void +fiv_io_premultiply_argb32(FivIoImage *image) +{ + if (image->format != CAIRO_FORMAT_ARGB32) + return; + + for (uint32_t y = 0; y < image->height; y++) { + uint32_t *dstp = (uint32_t *) (image->data + image->stride * y); + for (uint32_t x = 0; x < image->width; x++) { + uint32_t argb = dstp[x], a = argb >> 24; + dstp[x] = a << 24 | + PREMULTIPLY8(a, 0xFF & (argb >> 16)) << 16 | + PREMULTIPLY8(a, 0xFF & (argb >> 8)) << 8 | + PREMULTIPLY8(a, 0xFF & argb); + } + } +} + +// --- Profiles ---------------------------------------------------------------- +#ifdef HAVE_LCMS2 + +struct _FivIoProfile { + FivIoCmm *cmm; + cmsHPROFILE profile; +}; + +GBytes * +fiv_io_profile_to_bytes(FivIoProfile *profile) +{ + cmsUInt32Number len = 0; + (void) cmsSaveProfileToMem(profile, NULL, &len); + gchar *data = g_malloc0(len); + if (!cmsSaveProfileToMem(profile, data, &len)) { + g_free(data); + return NULL; + } + return g_bytes_new_take(data, len); +} + +static FivIoProfile * +fiv_io_profile_new(FivIoCmm *cmm, cmsHPROFILE profile) +{ + FivIoProfile *self = g_new0(FivIoProfile, 1); + self->cmm = g_object_ref(cmm); + self->profile = profile; + return self; +} + +void +fiv_io_profile_free(FivIoProfile *self) +{ + cmsCloseProfile(self->profile); + g_clear_object(&self->cmm); + g_free(self); +} + +#else // ! HAVE_LCMS2 + +GBytes *fiv_io_profile_to_bytes(FivIoProfile *) { return NULL; } +void fiv_io_profile_free(FivIoProfile *) {} + +#endif // ! HAVE_LCMS2 +// --- Contexts ---------------------------------------------------------------- +#ifdef HAVE_LCMS2 + +struct _FivIoCmm { + GObject parent_instance; + cmsContext context; + + // https://github.com/mm2/Little-CMS/issues/430 + gboolean broken_premul; +}; + +G_DEFINE_TYPE(FivIoCmm, fiv_io_cmm, G_TYPE_OBJECT) + +static void +fiv_io_cmm_finalize(GObject *gobject) +{ + FivIoCmm *self = FIV_IO_CMM(gobject); + cmsDeleteContext(self->context); + + G_OBJECT_CLASS(fiv_io_cmm_parent_class)->finalize(gobject); +} + +static void +fiv_io_cmm_class_init(FivIoCmmClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->finalize = fiv_io_cmm_finalize; +} + +static void +fiv_io_cmm_init(FivIoCmm *self) +{ + self->context = cmsCreateContext(NULL, self); +#ifdef HAVE_LCMS2_FAST_FLOAT + if (cmsPluginTHR(self->context, cmsFastFloatExtensions())) + self->broken_premul = LCMS_VERSION <= 2160; +#endif // HAVE_LCMS2_FAST_FLOAT +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +FivIoCmm * +fiv_io_cmm_get_default(void) +{ + static gsize initialization_value = 0; + static FivIoCmm *default_ = NULL; + if (g_once_init_enter(&initialization_value)) { + gsize setup_value = 1; + default_ = g_object_new(FIV_TYPE_IO_CMM, NULL); + g_once_init_leave(&initialization_value, setup_value); + } + return default_; +} + +FivIoProfile * +fiv_io_cmm_get_profile(FivIoCmm *self, const void *data, size_t len) +{ + g_return_val_if_fail(self != NULL, NULL); + + return fiv_io_profile_new(self, + cmsOpenProfileFromMemTHR(self->context, data, len)); +} + +FivIoProfile * +fiv_io_cmm_get_profile_sRGB(FivIoCmm *self) +{ + g_return_val_if_fail(self != NULL, NULL); + + return fiv_io_profile_new(self, + cmsCreate_sRGBProfileTHR(self->context)); +} + +FivIoProfile * +fiv_io_cmm_get_profile_parametric(FivIoCmm *self, + double gamma, double whitepoint[2], double primaries[6]) +{ + g_return_val_if_fail(self != NULL, NULL); + + const cmsCIExyY cmsWP = {whitepoint[0], whitepoint[1], 1.0}; + const cmsCIExyYTRIPLE cmsP = { + {primaries[0], primaries[1], 1.0}, + {primaries[2], primaries[3], 1.0}, + {primaries[4], primaries[5], 1.0}, + }; + + cmsToneCurve *curve = cmsBuildGamma(self->context, gamma); + if (!curve) + return NULL; + + cmsHPROFILE profile = cmsCreateRGBProfileTHR(self->context, + &cmsWP, &cmsP, (cmsToneCurve *[3]){curve, curve, curve}); + cmsFreeToneCurve(curve); + return fiv_io_profile_new(self, profile); +} + +#else // ! HAVE_LCMS2 + +FivIoCmm * +fiv_io_cmm_get_default() +{ + return NULL; +} + +FivIoProfile * +fiv_io_cmm_get_profile(FivIoCmm *, const void *, size_t) +{ + return NULL; +} + +FivIoProfile * +fiv_io_cmm_get_profile_sRGB(FivIoCmm *) +{ + return NULL; +} + +FivIoProfile * +fiv_io_cmm_get_profile_parametric(FivIoCmm *, double, double[2], double[6]) +{ + return NULL; +} + +#endif // ! HAVE_LCMS2 +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +FivIoProfile * +fiv_io_cmm_get_profile_sRGB_gamma(FivIoCmm *self, double gamma) +{ + return fiv_io_cmm_get_profile_parametric(self, gamma, + (double[2]){0.3127, 0.3290}, + (double[6]){0.6400, 0.3300, 0.3000, 0.6000, 0.1500, 0.0600}); +} + +FivIoProfile * +fiv_io_cmm_get_profile_from_bytes(FivIoCmm *self, GBytes *bytes) +{ + gsize len = 0; + gconstpointer p = g_bytes_get_data(bytes, &len); + return fiv_io_cmm_get_profile(self, p, len); +} + +// --- Image loading ----------------------------------------------------------- +#ifdef HAVE_LCMS2 + +// TODO(p): In general, try to use CAIRO_FORMAT_RGB30 or CAIRO_FORMAT_RGBA128F. +#define FIV_IO_PROFILE_ARGB32 \ + (G_BYTE_ORDER == G_LITTLE_ENDIAN ? TYPE_BGRA_8 : TYPE_ARGB_8) +#define FIV_IO_PROFILE_4X16LE \ + (G_BYTE_ORDER == G_LITTLE_ENDIAN ? TYPE_BGRA_16 : TYPE_BGRA_16_SE) + +void +fiv_io_cmm_cmyk(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target) +{ + g_return_if_fail(target == NULL || self != NULL); + + cmsHTRANSFORM transform = NULL; + if (source && target) { + transform = cmsCreateTransformTHR(self->context, + source->profile, TYPE_CMYK_8_REV, + target->profile, FIV_IO_PROFILE_ARGB32, INTENT_PERCEPTUAL, 0); + } + if (transform) { + cmsDoTransform( + transform, image->data, image->data, image->width * image->height); + cmsDeleteTransform(transform); + return; + } + trivial_cmyk_to_host_byte_order_argb( + image->data, image->width * image->height); +} + +static bool +fiv_io_cmm_rgb_direct(FivIoCmm *self, unsigned char *data, int w, int h, + FivIoProfile *source, FivIoProfile *target, + uint32_t source_format, uint32_t target_format) +{ + g_return_val_if_fail(target == NULL || self != NULL, false); + + // TODO(p): We should make this optional. + FivIoProfile *src_fallback = NULL; + if (target && !source) + source = src_fallback = fiv_io_cmm_get_profile_sRGB(self); + + cmsHTRANSFORM transform = NULL; + if (source && target) { + transform = cmsCreateTransformTHR(self->context, + source->profile, source_format, + target->profile, target_format, INTENT_PERCEPTUAL, 0); + } + if (transform) { + cmsDoTransform(transform, data, data, w * h); + cmsDeleteTransform(transform); + } + if (src_fallback) + fiv_io_profile_free(src_fallback); + return transform != NULL; +} + +static void +fiv_io_cmm_xrgb32(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target) +{ + fiv_io_cmm_rgb_direct(self, image->data, image->width, image->height, + source, target, FIV_IO_PROFILE_ARGB32, FIV_IO_PROFILE_ARGB32); +} + +void +fiv_io_cmm_4x16le_direct(FivIoCmm *self, unsigned char *data, + int w, int h, FivIoProfile *source, FivIoProfile *target) +{ + fiv_io_cmm_rgb_direct(self, data, w, h, source, target, + FIV_IO_PROFILE_4X16LE, FIV_IO_PROFILE_4X16LE); +} + +#else // ! HAVE_LCMS2 + +void +fiv_io_cmm_cmyk(FivIoCmm *, FivIoImage *image, FivIoProfile *, FivIoProfile *) +{ + trivial_cmyk_to_host_byte_order_argb( + image->data, image->width * image->height); +} + +static void +fiv_io_cmm_xrgb32(FivIoCmm *, FivIoImage *, FivIoProfile *, FivIoProfile *) +{ +} + +void +fiv_io_cmm_4x16le_direct( + FivIoCmm *, unsigned char *, int, int, FivIoProfile *, FivIoProfile *) +{ +} + +#endif // ! HAVE_LCMS2 +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +#if defined HAVE_LCMS2 && LCMS_VERSION >= 2130 + +#define FIV_IO_PROFILE_ARGB32_PREMUL \ + (G_BYTE_ORDER == G_LITTLE_ENDIAN ? TYPE_BGRA_8_PREMUL : TYPE_ARGB_8_PREMUL) + +static void +fiv_io_cmm_argb32(FivIoCmm *self, FivIoImage *image, + FivIoProfile *source, FivIoProfile *target) +{ + g_return_if_fail(image->format == CAIRO_FORMAT_ARGB32); + + // TODO: With self->broken_premul, + // this probably also needs to be wrapped in un-premultiplication. + fiv_io_cmm_rgb_direct(self, image->data, image->width, image->height, + source, target, + FIV_IO_PROFILE_ARGB32_PREMUL, FIV_IO_PROFILE_ARGB32_PREMUL); +} + +void +fiv_io_cmm_argb32_premultiply(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target) +{ + g_return_if_fail(target == NULL || self != NULL); + + if (image->format != CAIRO_FORMAT_ARGB32) { + fiv_io_cmm_xrgb32(self, image, source, target); + } else if (!target || self->broken_premul) { + fiv_io_cmm_xrgb32(self, image, source, target); + fiv_io_premultiply_argb32(image); + } else if (!fiv_io_cmm_rgb_direct(self, image->data, + image->width, image->height, source, target, + FIV_IO_PROFILE_ARGB32, FIV_IO_PROFILE_ARGB32_PREMUL)) { + g_debug("failed to create a premultiplying transform"); + fiv_io_premultiply_argb32(image); + } +} + +#else // ! HAVE_LCMS2 || LCMS_VERSION < 2130 + +static void +fiv_io_cmm_argb32(FivIoCmm *, FivIoImage *, FivIoProfile *, FivIoProfile *) +{ + // TODO(p): Unpremultiply, transform, repremultiply. Or require lcms2>=2.13. +} + +void +fiv_io_cmm_argb32_premultiply(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target) +{ + fiv_io_cmm_xrgb32(self, image, source, target); + fiv_io_premultiply_argb32(image); +} + +#endif // ! HAVE_LCMS2 || LCMS_VERSION < 2130 +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +void +fiv_io_cmm_page(FivIoCmm *self, FivIoImage *page, FivIoProfile *target, + void (*frame_cb) (FivIoCmm *, FivIoImage *, FivIoProfile *, FivIoProfile *)) +{ + FivIoProfile *source = NULL; + if (page->icc) + source = fiv_io_cmm_get_profile_from_bytes(self, page->icc); + + // TODO(p): All animations need to be composited in a linear colour space. + for (FivIoImage *frame = page; frame != NULL; frame = frame->frame_next) + frame_cb(self, frame, source, target); + + if (source) + fiv_io_profile_free(source); +} + +void +fiv_io_cmm_any(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target) +{ + // TODO(p): Ensure we do colour management early enough, so that + // no avoidable increase of quantization error occurs beforehands, + // and also for correct alpha compositing. + switch (image->format) { + break; case CAIRO_FORMAT_RGB24: + fiv_io_cmm_xrgb32(self, image, source, target); + break; case CAIRO_FORMAT_ARGB32: + fiv_io_cmm_argb32(self, image, source, target); + break; default: + g_debug("CM attempted on an unsupported surface format"); + } +} + +// TODO(p): Offer better integration, upgrade the bit depth if appropriate. +FivIoImage * +fiv_io_cmm_finish(FivIoCmm *self, FivIoImage *image, FivIoProfile *target) +{ + if (!target) + return image; + + for (FivIoImage *page = image; page != NULL; page = page->page_next) + fiv_io_cmm_page(self, page, target, fiv_io_cmm_any); + return image; +} diff --git a/fiv-io-model.c b/fiv-io-model.c new file mode 100644 index 0000000..3309702 --- /dev/null +++ b/fiv-io-model.c @@ -0,0 +1,742 @@ +// +// fiv-io-model.c: filesystem +// +// Copyright (c) 2021 - 2023, 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 "fiv-io.h" +#include "fiv-io-model.h" +#include "xdg.h" + +GType +fiv_io_model_sort_get_type(void) +{ + static gsize guard; + if (g_once_init_enter(&guard)) { +#define XX(name) {FIV_IO_MODEL_SORT_ ## name, \ + "FIV_IO_MODEL_SORT_" #name, #name}, + static const GEnumValue values[] = {FIV_IO_MODEL_SORTS(XX) {}}; +#undef XX + GType type = g_enum_register_static( + g_intern_static_string("FivIoModelSort"), values); + g_once_init_leave(&guard, type); + } + return guard; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +G_DEFINE_BOXED_TYPE(FivIoModelEntry, fiv_io_model_entry, + fiv_io_model_entry_ref, fiv_io_model_entry_unref) + +FivIoModelEntry * +fiv_io_model_entry_ref(FivIoModelEntry *self) +{ + return g_rc_box_acquire(self); +} + +void +fiv_io_model_entry_unref(FivIoModelEntry *self) +{ + g_rc_box_release(self); +} + +static size_t +entry_strsize(const char *string) +{ + if (!string) + return 0; + + return strlen(string) + 1; +} + +static char * +entry_strappend(char **p, const char *string, size_t size) +{ + if (!string) + return NULL; + + char *destination = memcpy(*p, string, size); + *p += size; + return destination; +} + +// See model_load_attributes for a (superset of a) list of required attributes. +static FivIoModelEntry * +entry_new(GFile *file, GFileInfo *info) +{ + gchar *uri = g_file_get_uri(file); + const gchar *target_uri = g_file_info_get_attribute_string( + info, G_FILE_ATTRIBUTE_STANDARD_TARGET_URI); + const gchar *display_name = g_file_info_get_display_name(info); + + // TODO(p): Make it possible to use g_utf8_collate_key() instead, + // which does not use natural sorting. + gchar *parse_name = g_file_get_parse_name(file); + gchar *collate_key = g_utf8_collate_key_for_filename(parse_name, -1); + g_free(parse_name); + + // The entries are immutable. Packing them into the structure + // should help memory usage as well as performance. + size_t size_uri = entry_strsize(uri); + size_t size_target_uri = entry_strsize(target_uri); + size_t size_display_name = entry_strsize(display_name); + size_t size_collate_key = entry_strsize(collate_key); + + FivIoModelEntry *entry = g_rc_box_alloc0(sizeof *entry + + size_uri + + size_target_uri + + size_display_name + + size_collate_key); + + gchar *p = (gchar *) entry + sizeof *entry; + entry->uri = entry_strappend(&p, uri, size_uri); + entry->target_uri = entry_strappend(&p, target_uri, size_target_uri); + entry->display_name = entry_strappend(&p, display_name, size_display_name); + entry->collate_key = entry_strappend(&p, collate_key, size_collate_key); + + entry->filesize = (guint64) g_file_info_get_size(info); + + GDateTime *mtime = g_file_info_get_modification_date_time(info); + if (mtime) { + entry->mtime_msec = g_date_time_to_unix(mtime) * 1000 + + g_date_time_get_microsecond(mtime) / 1000; + g_date_time_unref(mtime); + } + + g_free(uri); + g_free(collate_key); + return entry; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct _FivIoModel { + GObject parent_instance; + GPatternSpec **supported_patterns; + + GFile *directory; ///< Currently loaded directory + GFileMonitor *monitor; ///< "directory" monitoring + GPtrArray *subdirs; ///< "directory" contents + GPtrArray *files; ///< "directory" contents + + FivIoModelSort sort_field; ///< How to sort + gboolean sort_descending; ///< Whether to sort in reverse + gboolean filtering; ///< Only show non-hidden, supported +}; + +G_DEFINE_TYPE(FivIoModel, fiv_io_model, G_TYPE_OBJECT) + +enum { + PROP_FILTERING = 1, + PROP_SORT_FIELD, + PROP_SORT_DESCENDING, + N_PROPERTIES +}; + +static GParamSpec *model_properties[N_PROPERTIES]; + +enum { + RELOADED, + FILES_CHANGED, + SUBDIRECTORIES_CHANGED, + LAST_SIGNAL, +}; + +// Globals are, sadly, the canonical way of storing signal numbers. +static guint model_signals[LAST_SIGNAL]; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static GPtrArray * +model_entry_array_new(void) +{ + return g_ptr_array_new_with_free_func( + (GDestroyNotify) fiv_io_model_entry_unref); +} + +#if !GLIB_CHECK_VERSION(2, 70, 0) +#define g_pattern_spec_match g_pattern_match +#endif + +static gboolean +model_supports(FivIoModel *self, const char *filename) +{ + gchar *utf8 = g_filename_to_utf8(filename, -1, NULL, NULL, NULL); + if (!utf8) + return FALSE; + + gchar *lc = g_utf8_strdown(utf8, -1); + gsize lc_length = strlen(lc); + gchar *reversed = g_utf8_strreverse(lc, lc_length); + g_free(utf8); + + // fnmatch() uses the /locale encoding/, and isn't present on Windows. + // TODO(p): Consider using g_file_info_get_display_name() for direct UTF-8. + gboolean result = FALSE; + for (GPatternSpec **p = self->supported_patterns; *p; p++) + if ((result = g_pattern_spec_match(*p, lc_length, lc, reversed))) + break; + + g_free(lc); + g_free(reversed); + return result; +} + +static inline int +model_compare_entries(FivIoModel *self, + const FivIoModelEntry *entry1, GFile *file1, + const FivIoModelEntry *entry2, GFile *file2) +{ + if (g_file_has_prefix(file1, file2)) + return +1; + if (g_file_has_prefix(file2, file1)) + return -1; + + int result = 0; + switch (self->sort_field) { + case FIV_IO_MODEL_SORT_MTIME: + result -= entry1->mtime_msec < entry2->mtime_msec; + result += entry1->mtime_msec > entry2->mtime_msec; + if (result != 0) + break; + + // Fall-through + case FIV_IO_MODEL_SORT_NAME: + case FIV_IO_MODEL_SORT_COUNT: + result = strcmp(entry1->collate_key, entry2->collate_key); + } + return self->sort_descending ? -result : +result; +} + +static gint +model_compare(gconstpointer a, gconstpointer b, gpointer user_data) +{ + const FivIoModelEntry *entry1 = *(const FivIoModelEntry **) a; + const FivIoModelEntry *entry2 = *(const FivIoModelEntry **) b; + GFile *file1 = g_file_new_for_uri(entry1->uri); + GFile *file2 = g_file_new_for_uri(entry2->uri); + int result = model_compare_entries(user_data, entry1, file1, entry2, file2); + g_object_unref(file1); + g_object_unref(file2); + return result; +} + +static const char *model_load_attributes = + G_FILE_ATTRIBUTE_STANDARD_TYPE "," + G_FILE_ATTRIBUTE_STANDARD_NAME "," + G_FILE_ATTRIBUTE_STANDARD_SIZE "," + G_FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME "," + G_FILE_ATTRIBUTE_STANDARD_TARGET_URI "," + G_FILE_ATTRIBUTE_STANDARD_IS_HIDDEN "," + G_FILE_ATTRIBUTE_TIME_MODIFIED "," + G_FILE_ATTRIBUTE_TIME_MODIFIED_USEC; + +static GPtrArray * +model_decide_placement( + FivIoModel *self, GFileInfo *info, GPtrArray *subdirs, GPtrArray *files) +{ + if (self->filtering && + g_file_info_has_attribute(info, G_FILE_ATTRIBUTE_STANDARD_IS_HIDDEN) && + g_file_info_get_is_hidden(info)) + return NULL; + if (g_file_info_get_file_type(info) == G_FILE_TYPE_DIRECTORY) + return subdirs; + if (!self->filtering || + model_supports(self, g_file_info_get_name(info))) + return files; + return NULL; +} + +static gboolean +model_reload_to(FivIoModel *self, GFile *directory, + GPtrArray *subdirs, GPtrArray *files, GError **error) +{ + if (subdirs) + g_ptr_array_set_size(subdirs, 0); + if (files) + g_ptr_array_set_size(files, 0); + + GFileEnumerator *enumerator = g_file_enumerate_children( + directory, model_load_attributes, G_FILE_QUERY_INFO_NONE, NULL, error); + if (!enumerator) + return FALSE; + + GFileInfo *info = NULL; + GFile *child = NULL; + GError *e = NULL; + while (TRUE) { + if (!g_file_enumerator_iterate(enumerator, &info, &child, NULL, &e) && + e) { + g_warning("%s", e->message); + g_clear_error(&e); + continue; + } + if (!info) + break; + + GPtrArray *target = + model_decide_placement(self, info, subdirs, files); + if (target) + g_ptr_array_add(target, entry_new(child, info)); + } + g_object_unref(enumerator); + + if (subdirs) + g_ptr_array_sort_with_data(subdirs, model_compare, self); + if (files) + g_ptr_array_sort_with_data(files, model_compare, self); + return TRUE; +} + +static gboolean +model_reload(FivIoModel *self, GError **error) +{ + // Note that this will clear all entries on failure. + gboolean result = model_reload_to( + self, self->directory, self->subdirs, self->files, error); + g_signal_emit(self, model_signals[RELOADED], 0); + return result; +} + +static void +model_resort(FivIoModel *self) +{ + g_ptr_array_sort_with_data(self->subdirs, model_compare, self); + g_ptr_array_sort_with_data(self->files, model_compare, self); + g_signal_emit(self, model_signals[RELOADED], 0); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static gint +model_find(const GPtrArray *target, GFile *file, FivIoModelEntry **entry) +{ + for (guint i = 0; i < target->len; i++) { + FivIoModelEntry *e = target->pdata[i]; + GFile *f = g_file_new_for_uri(e->uri); + gboolean match = g_file_equal(f, file); + g_object_unref(f); + if (match) { + *entry = e; + return i; + } + } + return -1; +} + +enum monitor_event { + MONITOR_NONE, + MONITOR_CHANGING, + MONITOR_RENAMING, + MONITOR_REMOVING, + MONITOR_ADDING +}; + +static void +monitor_apply(enum monitor_event event, GPtrArray *target, int index, + FivIoModelEntry *new_entry) +{ + g_return_if_fail(event != MONITOR_CHANGING || index >= 0); + + if (event == MONITOR_RENAMING && index < 0) + // The file used to be filtered out but isn't anymore. + event = MONITOR_ADDING; + else if (!new_entry && index >= 0) + // The file wasn't filtered out but now it is. + event = MONITOR_REMOVING; + + if (event == MONITOR_CHANGING) { + fiv_io_model_entry_unref(target->pdata[index]); + target->pdata[index] = fiv_io_model_entry_ref(new_entry); + } + if (event == MONITOR_REMOVING || event == MONITOR_RENAMING) + g_ptr_array_remove_index(target, index); + if (event == MONITOR_RENAMING || event == MONITOR_ADDING) + g_ptr_array_add(target, fiv_io_model_entry_ref(new_entry)); +} + +static void +on_monitor_changed(G_GNUC_UNUSED GFileMonitor *monitor, GFile *file, + GFile *other_file, GFileMonitorEvent event_type, gpointer user_data) +{ + FivIoModel *self = user_data; + + FivIoModelEntry *old_entry = NULL; + gint files_index = model_find(self->files, file, &old_entry); + gint subdirs_index = model_find(self->subdirs, file, &old_entry); + + enum monitor_event event = MONITOR_NONE; + GFile *new_entry_file = NULL; + switch (event_type) { + case G_FILE_MONITOR_EVENT_CHANGED: + case G_FILE_MONITOR_EVENT_ATTRIBUTE_CHANGED: + event = MONITOR_CHANGING; + new_entry_file = file; + break; + case G_FILE_MONITOR_EVENT_RENAMED: + event = MONITOR_RENAMING; + new_entry_file = other_file; + break; + case G_FILE_MONITOR_EVENT_DELETED: + case G_FILE_MONITOR_EVENT_MOVED_OUT: + event = MONITOR_REMOVING; + break; + case G_FILE_MONITOR_EVENT_CREATED: + case G_FILE_MONITOR_EVENT_MOVED_IN: + event = MONITOR_ADDING; + old_entry = NULL; + new_entry_file = file; + break; + + case G_FILE_MONITOR_EVENT_CHANGES_DONE_HINT: + // TODO(p): Figure out if we can't make use of _CHANGES_DONE_HINT. + case G_FILE_MONITOR_EVENT_PRE_UNMOUNT: + case G_FILE_MONITOR_EVENT_UNMOUNTED: + // TODO(p): Figure out how to handle _UNMOUNTED sensibly. + case G_FILE_MONITOR_EVENT_MOVED: + return; + } + + FivIoModelEntry *new_entry = NULL; + GPtrArray *new_target = NULL; + if (new_entry_file) { + GError *error = NULL; + GFileInfo *info = g_file_query_info(new_entry_file, + model_load_attributes, G_FILE_QUERY_INFO_NONE, NULL, &error); + if (error) { + g_debug("monitor: %s", error->message); + g_error_free(error); + goto run; + } + + if ((new_target = + model_decide_placement(self, info, self->subdirs, self->files))) + new_entry = entry_new(new_entry_file, info); + g_object_unref(info); + + if ((files_index != -1 && new_target == self->subdirs) || + (subdirs_index != -1 && new_target == self->files)) { + g_debug("monitor: ignoring transfer between files and subdirs"); + goto out; + } + } + +run: + // Keep a reference alive so that signal handlers see the new arrays. + if (old_entry) + fiv_io_model_entry_ref(old_entry); + + if (files_index != -1 || new_target == self->files) { + monitor_apply(event, self->files, files_index, new_entry); + g_signal_emit(self, model_signals[FILES_CHANGED], + 0, old_entry, new_entry); + } + if (subdirs_index != -1 || new_target == self->subdirs) { + monitor_apply(event, self->subdirs, subdirs_index, new_entry); + g_signal_emit(self, model_signals[SUBDIRECTORIES_CHANGED], + 0, old_entry, new_entry); + } + + // NOTE: It would make sense to do + // g_ptr_array_sort_with_data(self->{files,subdirs}, model_compare, self); + // but then the iteration behaviour of fiv.c would differ from what's shown + // in the browser. Perhaps we need to use an index-based, fully-synchronized + // interface similar to GListModel::items-changed. + + if (old_entry) + fiv_io_model_entry_unref(old_entry); +out: + if (new_entry) + fiv_io_model_entry_unref(new_entry); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// This would be more efficient iteratively, but it's not that important. +static GFile * +model_last_deep_subdirectory(FivIoModel *self, GFile *directory) +{ + GFile *result = NULL; + GPtrArray *subdirs = model_entry_array_new(); + if (!model_reload_to(self, directory, subdirs, NULL, NULL)) + goto out; + + if (subdirs->len) { + FivIoModelEntry *entry = g_ptr_array_index(subdirs, subdirs->len - 1); + GFile *last = g_file_new_for_uri(entry->uri); + result = model_last_deep_subdirectory(self, last); + g_object_unref(last); + } else { + result = g_object_ref(directory); + } + +out: + g_ptr_array_free(subdirs, TRUE); + return result; +} + +GFile * +fiv_io_model_get_previous_directory(FivIoModel *self) +{ + g_return_val_if_fail(FIV_IS_IO_MODEL(self), NULL); + + GFile *parent_directory = g_file_get_parent(self->directory); + if (!parent_directory) + return NULL; + + GFile *result = NULL; + GPtrArray *subdirs = model_entry_array_new(); + if (!model_reload_to(self, parent_directory, subdirs, NULL, NULL)) + goto out; + + for (gsize i = 0; i < subdirs->len; i++) { + FivIoModelEntry *entry = g_ptr_array_index(subdirs, i); + GFile *file = g_file_new_for_uri(entry->uri); + if (g_file_equal(file, self->directory)) { + g_object_unref(file); + break; + } + + g_clear_object(&result); + result = file; + } + if (result) { + GFile *last = model_last_deep_subdirectory(self, result); + g_object_unref(result); + result = last; + } else { + result = g_object_ref(parent_directory); + } + +out: + g_object_unref(parent_directory); + g_ptr_array_free(subdirs, TRUE); + return result; +} + +// This would be more efficient iteratively, but it's not that important. +static GFile * +model_next_directory_within_parents(FivIoModel *self, GFile *directory) +{ + GFile *parent_directory = g_file_get_parent(directory); + if (!parent_directory) + return NULL; + + GFile *result = NULL; + GPtrArray *subdirs = model_entry_array_new(); + if (!model_reload_to(self, parent_directory, subdirs, NULL, NULL)) + goto out; + + gboolean found_self = FALSE; + for (gsize i = 0; i < subdirs->len; i++) { + FivIoModelEntry *entry = g_ptr_array_index(subdirs, i); + result = g_file_new_for_uri(entry->uri); + if (found_self) + goto out; + + found_self = g_file_equal(result, directory); + g_clear_object(&result); + } + if (!result) + result = model_next_directory_within_parents(self, parent_directory); + +out: + g_object_unref(parent_directory); + g_ptr_array_free(subdirs, TRUE); + return result; +} + +GFile * +fiv_io_model_get_next_directory(FivIoModel *self) +{ + g_return_val_if_fail(FIV_IS_IO_MODEL(self), NULL); + + if (self->subdirs->len) { + FivIoModelEntry *entry = g_ptr_array_index(self->subdirs, 0); + return g_file_new_for_uri(entry->uri); + } + + return model_next_directory_within_parents(self, self->directory); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +fiv_io_model_finalize(GObject *gobject) +{ + FivIoModel *self = FIV_IO_MODEL(gobject); + for (GPatternSpec **p = self->supported_patterns; *p; p++) + g_pattern_spec_free(*p); + g_free(self->supported_patterns); + + g_clear_object(&self->directory); + g_clear_object(&self->monitor); + g_ptr_array_free(self->subdirs, TRUE); + g_ptr_array_free(self->files, TRUE); + + G_OBJECT_CLASS(fiv_io_model_parent_class)->finalize(gobject); +} + +static void +fiv_io_model_get_property( + GObject *object, guint property_id, GValue *value, GParamSpec *pspec) +{ + FivIoModel *self = FIV_IO_MODEL(object); + switch (property_id) { + case PROP_FILTERING: + g_value_set_boolean(value, self->filtering); + break; + case PROP_SORT_FIELD: + g_value_set_enum(value, self->sort_field); + break; + case PROP_SORT_DESCENDING: + g_value_set_boolean(value, self->sort_descending); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec); + } +} + +static void +fiv_io_model_set_property( + GObject *object, guint property_id, const GValue *value, GParamSpec *pspec) +{ + FivIoModel *self = FIV_IO_MODEL(object); + switch (property_id) { + case PROP_FILTERING: + if (self->filtering != g_value_get_boolean(value)) { + self->filtering = !self->filtering; + g_object_notify_by_pspec(object, model_properties[property_id]); + (void) model_reload(self, NULL /* error */); + } + break; + case PROP_SORT_FIELD: + if ((int) self->sort_field != g_value_get_enum(value)) { + self->sort_field = g_value_get_enum(value); + g_object_notify_by_pspec(object, model_properties[property_id]); + model_resort(self); + } + break; + case PROP_SORT_DESCENDING: + if (self->sort_descending != g_value_get_boolean(value)) { + self->sort_descending = !self->sort_descending; + g_object_notify_by_pspec(object, model_properties[property_id]); + model_resort(self); + } + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec); + } +} + +static void +fiv_io_model_class_init(FivIoModelClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->get_property = fiv_io_model_get_property; + object_class->set_property = fiv_io_model_set_property; + object_class->finalize = fiv_io_model_finalize; + + model_properties[PROP_FILTERING] = g_param_spec_boolean( + "filtering", "Filtering", "Only show non-hidden, supported entries", + TRUE, G_PARAM_READWRITE); + model_properties[PROP_SORT_FIELD] = g_param_spec_enum( + "sort-field", "Sort field", "Sort order", + FIV_TYPE_IO_MODEL_SORT, FIV_IO_MODEL_SORT_NAME, G_PARAM_READWRITE); + model_properties[PROP_SORT_DESCENDING] = g_param_spec_boolean( + "sort-descending", "Sort descending", "Use reverse sort order", + FALSE, G_PARAM_READWRITE); + g_object_class_install_properties( + object_class, N_PROPERTIES, model_properties); + + // All entries might have changed. + model_signals[RELOADED] = + g_signal_new("reloaded", G_TYPE_FROM_CLASS(klass), 0, 0, + NULL, NULL, NULL, G_TYPE_NONE, 0); + + model_signals[FILES_CHANGED] = + g_signal_new("files-changed", G_TYPE_FROM_CLASS(klass), 0, 0, + NULL, NULL, NULL, + G_TYPE_NONE, 2, FIV_TYPE_IO_MODEL_ENTRY, FIV_TYPE_IO_MODEL_ENTRY); + model_signals[SUBDIRECTORIES_CHANGED] = + g_signal_new("subdirectories-changed", G_TYPE_FROM_CLASS(klass), 0, 0, + NULL, NULL, NULL, + G_TYPE_NONE, 2, FIV_TYPE_IO_MODEL_ENTRY, FIV_TYPE_IO_MODEL_ENTRY); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +fiv_io_model_init(FivIoModel *self) +{ + self->filtering = TRUE; + + char **types = fiv_io_all_supported_media_types(); + char **globs = extract_mime_globs((const char **) types); + g_strfreev(types); + + gsize n = g_strv_length(globs); + self->supported_patterns = + g_malloc0_n(n + 1, sizeof *self->supported_patterns); + while (n--) + self->supported_patterns[n] = g_pattern_spec_new(globs[n]); + g_strfreev(globs); + + self->files = model_entry_array_new(); + self->subdirs = model_entry_array_new(); +} + +gboolean +fiv_io_model_open(FivIoModel *self, GFile *directory, GError **error) +{ + g_return_val_if_fail(FIV_IS_IO_MODEL(self), FALSE); + g_return_val_if_fail(G_IS_FILE(directory), FALSE); + + g_clear_object(&self->directory); + g_clear_object(&self->monitor); + self->directory = g_object_ref(directory); + + GError *e = NULL; + if ((self->monitor = g_file_monitor_directory( + directory, G_FILE_MONITOR_WATCH_MOVES, NULL, &e))) { + g_signal_connect(self->monitor, "changed", + G_CALLBACK(on_monitor_changed), self); + } else { + g_debug("directory monitoring failed: %s", e->message); + g_error_free(e); + } + return model_reload(self, error); +} + +GFile * +fiv_io_model_get_location(FivIoModel *self) +{ + g_return_val_if_fail(FIV_IS_IO_MODEL(self), NULL); + return self->directory; +} + +FivIoModelEntry *const * +fiv_io_model_get_files(FivIoModel *self, gsize *len) +{ + *len = self->files->len; + return (FivIoModelEntry *const *) self->files->pdata; +} + +FivIoModelEntry *const * +fiv_io_model_get_subdirs(FivIoModel *self, gsize *len) +{ + *len = self->subdirs->len; + return (FivIoModelEntry *const *) self->subdirs->pdata; +} diff --git a/fiv-io-model.h b/fiv-io-model.h new file mode 100644 index 0000000..c785130 --- /dev/null +++ b/fiv-io-model.h @@ -0,0 +1,72 @@ +// +// fiv-io-model.h: filesystem +// +// Copyright (c) 2021 - 2023, 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. +// + +#pragma once + +#include <gio/gio.h> +#include <glib.h> + +// Avoid glib-mkenums. +typedef enum _FivIoModelSort { +#define FIV_IO_MODEL_SORTS(XX) \ + XX(NAME) \ + XX(MTIME) +#define XX(name) FIV_IO_MODEL_SORT_ ## name, + FIV_IO_MODEL_SORTS(XX) +#undef XX + FIV_IO_MODEL_SORT_COUNT +} FivIoModelSort; + +GType fiv_io_model_sort_get_type(void) G_GNUC_CONST; +#define FIV_TYPE_IO_MODEL_SORT (fiv_io_model_sort_get_type()) + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +typedef struct { + const char *uri; ///< GIO URI + const char *target_uri; ///< GIO URI for any target + const char *display_name; ///< Label for the file + const char *collate_key; ///< Collate key for the filename + guint64 filesize; ///< Filesize in bytes + gint64 mtime_msec; ///< Modification time in milliseconds +} FivIoModelEntry; + +GType fiv_io_model_entry_get_type(void) G_GNUC_CONST; +#define FIV_TYPE_IO_MODEL_ENTRY (fiv_io_model_entry_get_type()) + +FivIoModelEntry *fiv_io_model_entry_ref(FivIoModelEntry *self); +void fiv_io_model_entry_unref(FivIoModelEntry *self); + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +#define FIV_TYPE_IO_MODEL (fiv_io_model_get_type()) +G_DECLARE_FINAL_TYPE(FivIoModel, fiv_io_model, FIV, IO_MODEL, GObject) + +/// Loads a directory. Clears itself even on failure. +gboolean fiv_io_model_open(FivIoModel *self, GFile *directory, GError **error); + +/// Returns the current location as a GFile. +/// There is no ownership transfer, and the object may be NULL. +GFile *fiv_io_model_get_location(FivIoModel *self); + +/// Returns the previous VFS directory in order, or NULL. +GFile *fiv_io_model_get_previous_directory(FivIoModel *self); +/// Returns the next VFS directory in order, or NULL. +GFile *fiv_io_model_get_next_directory(FivIoModel *self); + +FivIoModelEntry *const *fiv_io_model_get_files(FivIoModel *self, gsize *len); +FivIoModelEntry *const *fiv_io_model_get_subdirs(FivIoModel *self, gsize *len); @@ -1,7 +1,7 @@ // // fiv-io.c: image operations // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2024, 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. @@ -19,30 +19,32 @@ #include <errno.h> #include <math.h> +#include <setjmp.h> +#include <stdio.h> #include <cairo.h> #include <glib.h> +#include <jpeglib.h> #include <turbojpeg.h> #include <webp/decode.h> #include <webp/demux.h> #include <webp/encode.h> #include <webp/mux.h> - #ifdef HAVE_JPEG_QS -#include <setjmp.h> -#include <stdio.h> - -#include <jpeglib.h> #include <libjpegqs.h> #endif // HAVE_JPEG_QS -// Colour management must be handled before RGB conversions. -#ifdef HAVE_LCMS2 -#include <lcms2.h> -#endif // HAVE_LCMS2 +#define TIFF_TABLES_CONSTANTS_ONLY +#include "tiff-tables.h" +#include "tiffer.h" #ifdef HAVE_LIBRAW #include <libraw.h> +#if LIBRAW_VERSION >= LIBRAW_MAKE_VERSION(0, 21, 0) +#define LIBRAW_OPIONS_NO_MEMERR_CALLBACK 0 +#else +#define rawparams params +#endif #endif // HAVE_LIBRAW #ifdef HAVE_RESVG #include <resvg.h> @@ -77,7 +79,7 @@ #define WUFFS_CONFIG__MODULE__PNG #define WUFFS_CONFIG__MODULE__TGA #define WUFFS_CONFIG__MODULE__ZLIB -#include "wuffs-mirror-release-c/release/c/wuffs-v0.3.c" +#include "submodules/wuffs-mirror-release-c/release/c/wuffs-v0.3.c" #include "fiv-io.h" @@ -118,23 +120,28 @@ const char *fiv_io_supported_media_types[] = { gchar ** fiv_io_all_supported_media_types(void) { + GHashTable *unique = + g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); GPtrArray *types = g_ptr_array_new(); for (const char **p = fiv_io_supported_media_types; *p; p++) - g_ptr_array_add(types, g_strdup(*p)); + if (g_hash_table_insert(unique, g_strdup(*p), NULL)) + g_ptr_array_add(types, g_strdup(*p)); #ifdef HAVE_GDKPIXBUF GSList *formats = gdk_pixbuf_get_formats(); for (GSList *iter = formats; iter; iter = iter->next) { gchar **subtypes = gdk_pixbuf_format_get_mime_types(iter->data); for (gchar **p = subtypes; *p; p++) - g_ptr_array_add(types, *p); + if (g_hash_table_insert(unique, *p, NULL)) + g_ptr_array_add(types, g_strdup(*p)); g_free(subtypes); } g_slist_free(formats); #endif // HAVE_GDKPIXBUF + g_hash_table_unref(unique); g_ptr_array_add(types, NULL); - return (char **) g_ptr_array_free(types, FALSE); + return (gchar **) g_ptr_array_free(types, FALSE); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -168,305 +175,117 @@ add_warning(const FivIoOpenContext *ctx, const char *format, ...) va_end(ap); } -static bool -try_append_page(cairo_surface_t *surface, cairo_surface_t **result, - cairo_surface_t **result_tail) -{ - if (!surface) - return false; +// --- Images ------------------------------------------------------------------ - if (*result) { - cairo_surface_set_user_data(*result_tail, &fiv_io_key_page_next, - surface, (cairo_destroy_func_t) cairo_surface_destroy); - cairo_surface_set_user_data( - surface, &fiv_io_key_page_previous, *result_tail, NULL); - *result_tail = surface; - } else { - *result = *result_tail = surface; +FivIoImage * +fiv_io_image_new(cairo_format_t format, uint32_t width, uint32_t height) +{ + // CAIRO_STRIDE_ALIGNMENT is 4 bytes, we only use multiples. + size_t unit = 0; + switch (format) { + case CAIRO_FORMAT_RGB24: + case CAIRO_FORMAT_RGB30: + case CAIRO_FORMAT_ARGB32: + unit = 4; + break; +#if CAIRO_VERSION >= 11702 + case CAIRO_FORMAT_RGB96F: + unit = 12; + break; + case CAIRO_FORMAT_RGBA128F: + unit = 16; + break; +#endif + default: + return NULL; } - return true; -} -// --- Colour management ------------------------------------------------------- + uint8_t *data = g_try_malloc0(unit * width * height); + if (!data) + return NULL; -FivIoProfile -fiv_io_profile_new(const void *data, size_t len) -{ -#ifdef HAVE_LCMS2 - return cmsOpenProfileFromMem(data, len); -#else - (void) data; - (void) len; - return NULL; -#endif + FivIoImage *image = g_rc_box_new0(FivIoImage); + image->data = data; + image->format = format; + image->width = width; + image->stride = width * unit; + image->height = height; + return image; } -FivIoProfile -fiv_io_profile_new_sRGB(void) +FivIoImage * +fiv_io_image_ref(FivIoImage *self) { -#ifdef HAVE_LCMS2 - return cmsCreate_sRGBProfile(); -#else - return NULL; -#endif + return g_rc_box_acquire(self); } -FivIoProfile -fiv_io_profile_new_sRGB_gamma(double gamma) +static void +fiv_io_image_finalize(FivIoImage *image) { -#ifdef HAVE_LCMS2 - // TODO(p): Make sure to use the library in a thread-safe manner. - cmsContext context = NULL; + g_free(image->data); - static const cmsCIExyY D65 = {0.3127, 0.3290, 1.0}; - static const cmsCIExyYTRIPLE primaries = { - {0.6400, 0.3300, 1.0}, {0.3000, 0.6000, 1.0}, {0.1500, 0.0600, 1.0}}; - cmsToneCurve *curve = cmsBuildGamma(context, gamma); - if (!curve) - return NULL; + g_bytes_unref(image->exif); + g_bytes_unref(image->icc); + g_bytes_unref(image->xmp); + g_bytes_unref(image->thum); - cmsHPROFILE profile = cmsCreateRGBProfileTHR( - context, &D65, &primaries, (cmsToneCurve *[3]){curve, curve, curve}); - cmsFreeToneCurve(curve); - return profile; -#else - (void) gamma; - return NULL; -#endif -} + if (image->text) + g_hash_table_unref(image->text); -static FivIoProfile -fiv_io_profile_new_from_bytes(GBytes *bytes) -{ - gsize len = 0; - gconstpointer p = g_bytes_get_data(bytes, &len); - return fiv_io_profile_new(p, len); -} + if (image->render) + image->render->destroy(image->render); -static GBytes * -fiv_io_profile_to_bytes(FivIoProfile profile) -{ -#ifdef HAVE_LCMS2 - cmsUInt32Number len = 0; - (void) cmsSaveProfileToMem(profile, NULL, &len); - gchar *data = g_malloc0(len); - if (!cmsSaveProfileToMem(profile, data, &len)) { - g_free(data); - return NULL; - } - return g_bytes_new_take(data, len); -#else - (void) profile; - return NULL; -#endif + if (image->page_next) + fiv_io_image_unref(image->page_next); + if (image->frame_next) + fiv_io_image_unref(image->frame_next); } void -fiv_io_profile_free(FivIoProfile self) +fiv_io_image_unref(FivIoImage *self) { -#ifdef HAVE_LCMS2 - cmsCloseProfile(self); -#else - (void) self; -#endif + g_rc_box_release_full(self, (GDestroyNotify) fiv_io_image_finalize); } -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// TODO(p): In general, try to use CAIRO_FORMAT_RGB30 or CAIRO_FORMAT_RGBA128F. -#define FIV_IO_LCMS2_ARGB32 \ - (G_BYTE_ORDER == G_LITTLE_ENDIAN ? TYPE_BGRA_8 : TYPE_ARGB_8) -#define FIV_IO_LCMS2_4X16LE \ - (G_BYTE_ORDER == G_LITTLE_ENDIAN ? TYPE_BGRA_16 : TYPE_BGRA_16_SE) - -// CAIRO_STRIDE_ALIGNMENT is 4 bytes, so there will be no padding with -// ARGB/BGRA/XRGB/BGRX. -static void -trivial_cmyk_to_host_byte_order_argb(unsigned char *p, int len) -{ - // This CMYK handling has been seen in gdk-pixbuf/JPEG, GIMP/JPEG, skcms. - // It will typically produce horribly oversaturated results. - // Assume that all YCCK/CMYK JPEG files use inverted CMYK, as Photoshop - // does, see https://bugzilla.gnome.org/show_bug.cgi?id=618096 - while (len--) { - int c = p[0], m = p[1], y = p[2], k = p[3]; -#if G_BYTE_ORDER == G_LITTLE_ENDIAN - p[0] = k * y / 255; - p[1] = k * m / 255; - p[2] = k * c / 255; - p[3] = 255; -#else - p[3] = k * y / 255; - p[2] = k * m / 255; - p[1] = k * c / 255; - p[0] = 255; -#endif - p += 4; - } -} - -static void -fiv_io_profile_cmyk( - cairo_surface_t *surface, FivIoProfile source, FivIoProfile target) +cairo_surface_t * +fiv_io_image_to_surface_noref(const FivIoImage *image) { - unsigned char *data = cairo_image_surface_get_data(surface); - int w = cairo_image_surface_get_width(surface); - int h = cairo_image_surface_get_height(surface); - -#ifndef HAVE_LCMS2 - (void) source; - (void) target; -#else - cmsHTRANSFORM transform = NULL; - if (source && target) { - transform = cmsCreateTransform(source, TYPE_CMYK_8_REV, target, - FIV_IO_LCMS2_ARGB32, INTENT_PERCEPTUAL, 0); - } - if (transform) { - cmsDoTransform(transform, data, data, w * h); - cmsDeleteTransform(transform); - return; - } -#endif - trivial_cmyk_to_host_byte_order_argb(data, w * h); + return cairo_image_surface_create_for_data( + image->data, image->format, image->width, image->height, image->stride); } -static void -fiv_io_profile_xrgb32_direct(unsigned char *data, int w, int h, - FivIoProfile source, FivIoProfile target) -{ -#ifndef HAVE_LCMS2 - (void) data; - (void) w; - (void) h; - (void) source; - (void) target; -#else - // TODO(p): We should make this optional. - cmsHPROFILE src_fallback = NULL; - if (target && !source) - source = src_fallback = cmsCreate_sRGBProfile(); - - cmsHTRANSFORM transform = NULL; - if (source && target) { - transform = cmsCreateTransform(source, FIV_IO_LCMS2_ARGB32, target, - FIV_IO_LCMS2_ARGB32, INTENT_PERCEPTUAL, 0); - } - if (transform) { - cmsDoTransform(transform, data, data, w * h); - cmsDeleteTransform(transform); - } - if (src_fallback) - cmsCloseProfile(src_fallback); -#endif -} - -static void -fiv_io_profile_xrgb32( - cairo_surface_t *surface, FivIoProfile source, FivIoProfile target) +cairo_surface_t * +fiv_io_image_to_surface(FivIoImage *image) { - unsigned char *data = cairo_image_surface_get_data(surface); - int w = cairo_image_surface_get_width(surface); - int h = cairo_image_surface_get_height(surface); - fiv_io_profile_xrgb32_direct(data, w, h, source, target); -} + // TODO(p): Remove this shortcut eventually. And the function. + if (!image) + return NULL; -static void -fiv_io_profile_4x16le_direct( - unsigned char *data, int w, int h, FivIoProfile source, FivIoProfile target) -{ -#ifndef HAVE_LCMS2 - (void) data; - (void) w; - (void) h; - (void) source; - (void) target; -#else - // TODO(p): We should make this optional. - cmsHPROFILE src_fallback = NULL; - if (target && !source) - source = src_fallback = cmsCreate_sRGBProfile(); - - cmsHTRANSFORM transform = NULL; - if (source && target) { - transform = cmsCreateTransform(source, FIV_IO_LCMS2_4X16LE, target, - FIV_IO_LCMS2_4X16LE, INTENT_PERCEPTUAL, 0); - } - if (transform) { - cmsDoTransform(transform, data, data, w * h); - cmsDeleteTransform(transform); - } - if (src_fallback) - cmsCloseProfile(src_fallback); -#endif + static cairo_user_data_key_t key_image; + cairo_surface_t *surface = cairo_image_surface_create_for_data( + image->data, image->format, image->width, image->height, image->stride); + cairo_surface_set_user_data(surface, &key_image, + image, (cairo_destroy_func_t) fiv_io_image_unref); + return surface; } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static void -fiv_io_profile_xrgb32_page(cairo_surface_t *page, FivIoProfile target) -{ - GBytes *bytes = NULL; - FivIoProfile source = NULL; - if ((bytes = cairo_surface_get_user_data(page, &fiv_io_key_icc))) - source = fiv_io_profile_new_from_bytes(bytes); - - // TODO(p): All animations need to be composited in a linear colour space. - for (cairo_surface_t *frame = page; frame != NULL; - frame = cairo_surface_get_user_data(frame, &fiv_io_key_frame_next)) - fiv_io_profile_xrgb32(frame, source, target); - - if (source) - fiv_io_profile_free(source); -} - -// TODO(p): Offer better integration, upgrade the bit depth if appropriate. -static cairo_surface_t * -fiv_io_profile_finalize(cairo_surface_t *image, FivIoProfile target) -{ - if (!image || !target) - return image; - - for (cairo_surface_t *page = image; page != NULL; - page = cairo_surface_get_user_data(page, &fiv_io_key_page_next)) { - // TODO(p): 1. un/premultiply ARGB, 2. do colour management - // early enough, so that no avoidable increase of quantization error - // occurs beforehands, and also for correct alpha compositing. - // FIXME: This assumes that if the first frame is opaque, they all are. - if (cairo_image_surface_get_format(page) == CAIRO_FORMAT_RGB24) - fiv_io_profile_xrgb32_page(page, target); - } - return image; -} - -static void -fiv_io_premultiply_argb32(cairo_surface_t *surface) +static bool +try_append_page( + FivIoImage *image, FivIoImage **result, FivIoImage **result_tail) { - int w = cairo_image_surface_get_width(surface); - int h = cairo_image_surface_get_height(surface); - unsigned char *data = cairo_image_surface_get_data(surface); - int stride = cairo_image_surface_get_stride(surface); - if (cairo_image_surface_get_format(surface) != CAIRO_FORMAT_ARGB32) - return; + if (!image) + return false; - for (int y = 0; y < h; y++) { - uint32_t *dstp = (uint32_t *) (data + stride * y); - for (int x = 0; x < w; x++) { - uint32_t argb = dstp[x], a = argb >> 24; - dstp[x] = a << 24 | - PREMULTIPLY8(a, 0xFF & (argb >> 16)) << 16 | - PREMULTIPLY8(a, 0xFF & (argb >> 8)) << 8 | - PREMULTIPLY8(a, 0xFF & argb); - } + if (*result) { + (*result_tail)->page_next = image; + image->page_previous = *result_tail; + *result_tail = image; + } else { + *result = *result_tail = image; } -} - -static void -fiv_io_premultiply_argb32_page(cairo_surface_t *page) -{ - for (cairo_surface_t *frame = page; frame != NULL; - frame = cairo_surface_get_user_data(frame, &fiv_io_key_frame_next)) - fiv_io_premultiply_argb32(frame); + return true; } // --- Wuffs ------------------------------------------------------------------- @@ -562,11 +381,12 @@ struct load_wuffs_frame_context { GBytes *meta_iccp; ///< Reference-counted ICC profile GBytes *meta_xmp; ///< Reference-counted XMP - FivIoProfile target; ///< Target device profile, if any - FivIoProfile source; ///< Source colour profile, if any + FivIoCmm *cmm; ///< CMM context, if any + FivIoProfile *target; ///< Target device profile, if any + FivIoProfile *source; ///< Source colour profile, if any - cairo_surface_t *result; ///< The resulting surface (referenced) - cairo_surface_t *result_tail; ///< The final animation frame + FivIoImage *result; ///< The resulting image (referenced) + FivIoImage *result_tail; ///< The final animation frame }; static bool @@ -593,66 +413,58 @@ load_wuffs_frame(struct load_wuffs_frame_context *ctx, GError **error) decode_format = CAIRO_FORMAT_ARGB32; unsigned char *targetbuf = NULL; - cairo_surface_t *surface = - cairo_image_surface_create(decode_format, ctx->width, ctx->height); - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); + FivIoImage *image = + fiv_io_image_new(decode_format, ctx->width, ctx->height); + if (!image) { + set_error(error, "image allocation failure"); goto fail; } - // CAIRO_STRIDE_ALIGNMENT is 4 bytes, so there will be no padding with - // ARGB/BGR/XRGB/BGRX. This function does not support a stride different - // from the width, maybe Wuffs internals do not either. - unsigned char *surface_data = cairo_image_surface_get_data(surface); - int surface_stride = cairo_image_surface_get_stride(surface); + // There is no padding with ARGB/BGR/XRGB/BGRX. + // This function does not support a stride different from the width, + // maybe Wuffs internals do not either. wuffs_base__pixel_buffer pb = {0}; if (ctx->expand_16_float || ctx->pack_16_10) { - uint32_t targetbuf_size = ctx->height * ctx->width * 8; + uint32_t targetbuf_size = image->height * image->width * 8; targetbuf = g_malloc(targetbuf_size); status = wuffs_base__pixel_buffer__set_from_slice(&pb, &ctx->cfg.pixcfg, wuffs_base__make_slice_u8(targetbuf, targetbuf_size)); } else { status = wuffs_base__pixel_buffer__set_from_slice(&pb, &ctx->cfg.pixcfg, - wuffs_base__make_slice_u8(surface_data, - surface_stride * cairo_image_surface_get_height(surface))); + wuffs_base__make_slice_u8( + image->data, image->stride * image->height)); } if (!wuffs_base__status__is_ok(&status)) { set_error(error, wuffs_base__status__message(&status)); goto fail; } - // Starting to modify pixel data directly. Probably an unnecessary call. - cairo_surface_flush(surface); - status = wuffs_base__image_decoder__decode_frame(ctx->dec, &pb, ctx->src, WUFFS_BASE__PIXEL_BLEND__SRC, ctx->workbuf, NULL); if (!wuffs_base__status__is_ok(&status)) { set_error(error, wuffs_base__status__message(&status)); - // The PNG decoder, at minimum, will flush any pixel data, so use them. - if (status.repr != wuffs_base__suspension__short_read) - goto fail; + // The PNG decoder, at minimum, will flush any pixel data upon + // finding out that the input is truncated, so accept whatever we get. } if (ctx->target) { if (ctx->expand_16_float || ctx->pack_16_10) { - fiv_io_profile_4x16le_direct( + fiv_io_cmm_4x16le_direct(ctx->cmm, targetbuf, ctx->width, ctx->height, ctx->source, ctx->target); // The first one premultiplies below, the second doesn't need to. } else { - fiv_io_profile_xrgb32_direct(surface_data, ctx->width, ctx->height, - ctx->source, ctx->target); - fiv_io_premultiply_argb32(surface); + fiv_io_cmm_argb32_premultiply( + ctx->cmm, image, ctx->source, ctx->target); } } if (ctx->expand_16_float) { g_debug("Wuffs to Cairo RGBA128F"); uint16_t *in = (uint16_t *) targetbuf; - float *out = (float *) surface_data; - for (uint32_t y = 0; y < ctx->height; y++) { - for (uint32_t x = 0; x < ctx->width; x++) { + float *out = (float *) image->data; + for (uint32_t y = 0; y < image->height; y++) { + for (uint32_t x = 0; x < image->width; x++) { float b = *in++ / 65535., g = *in++ / 65535., r = *in++ / 65535., a = *in++ / 65535.; *out++ = r * a; @@ -664,9 +476,9 @@ load_wuffs_frame(struct load_wuffs_frame_context *ctx, GError **error) } else if (ctx->pack_16_10) { g_debug("Wuffs to Cairo RGB30"); uint16_t *in = (uint16_t *) targetbuf; - uint32_t *out = (uint32_t *) surface_data; - for (uint32_t y = 0; y < ctx->height; y++) { - for (uint32_t x = 0; x < ctx->width; x++) { + uint32_t *out = (uint32_t *) image->data; + for (uint32_t y = 0; y < image->height; y++) { + for (uint32_t x = 0; x < image->width; x++) { uint32_t b = *in++, g = *in++, r = *in++, X = *in++; *out++ = (X >> 14) << 30 | (r >> 6) << 20 | (g >> 6) << 10 | (b >> 6); @@ -674,19 +486,17 @@ load_wuffs_frame(struct load_wuffs_frame_context *ctx, GError **error) } } - // Pixel data has been written, need to let Cairo know. - cairo_surface_mark_dirty(surface); - // Single-frame images get a fast path, animations are are handled slowly: if (wuffs_base__frame_config__index(&fc) > 0) { - // Copy the previous frame to a new surface. - cairo_surface_t *canvas = cairo_image_surface_create( - ctx->cairo_format, ctx->width, ctx->height); - int stride = cairo_image_surface_get_stride(canvas); - int height = cairo_image_surface_get_height(canvas); - memcpy(cairo_image_surface_get_data(canvas), - cairo_image_surface_get_data(ctx->result_tail), stride * height); - cairo_surface_mark_dirty(canvas); + // Copy the previous frame to a new image. + FivIoImage *prev = ctx->result_tail, *canvas = fiv_io_image_new( + prev->format, prev->width, prev->height); + if (!canvas) { + set_error(error, "image allocation failure"); + goto fail; + } + + memcpy(canvas->data, prev->data, prev->stride * prev->height); // Apply that frame's disposal method. // XXX: We do not expect opaque pictures to receive holes this way. @@ -703,7 +513,9 @@ load_wuffs_frame(struct load_wuffs_frame_context *ctx, GError **error) b = (uint8_t) (bg) / 255. / a; } - cairo_t *cr = cairo_create(canvas); + cairo_surface_t *surface = fiv_io_image_to_surface_noref(canvas); + cairo_t *cr = cairo_create(surface); + cairo_surface_destroy(surface); switch (wuffs_base__frame_config__disposal(&ctx->last_fc)) { case WUFFS_BASE__ANIMATION_DISPOSAL__RESTORE_BACKGROUND: cairo_rectangle(cr, bounds.min_incl_x, bounds.min_incl_y, @@ -733,46 +545,41 @@ load_wuffs_frame(struct load_wuffs_frame_context *ctx, GError **error) ? CAIRO_OPERATOR_SOURCE : CAIRO_OPERATOR_OVER); + surface = fiv_io_image_to_surface_noref(image); cairo_set_source_surface(cr, surface, 0, 0); + cairo_surface_destroy(surface); cairo_paint(cr); cairo_destroy(cr); - cairo_surface_destroy(surface); - surface = canvas; + + fiv_io_image_unref(image); + image = canvas; } if (ctx->meta_exif) - cairo_surface_set_user_data(surface, &fiv_io_key_exif, - g_bytes_ref(ctx->meta_exif), (cairo_destroy_func_t) g_bytes_unref); + image->exif = g_bytes_ref(ctx->meta_exif); if (ctx->meta_iccp) - cairo_surface_set_user_data(surface, &fiv_io_key_icc, - g_bytes_ref(ctx->meta_iccp), (cairo_destroy_func_t) g_bytes_unref); + image->icc = g_bytes_ref(ctx->meta_iccp); if (ctx->meta_xmp) - cairo_surface_set_user_data(surface, &fiv_io_key_xmp, - g_bytes_ref(ctx->meta_xmp), (cairo_destroy_func_t) g_bytes_unref); + image->xmp = g_bytes_ref(ctx->meta_xmp); - cairo_surface_set_user_data(surface, &fiv_io_key_loops, - (void *) (uintptr_t) wuffs_base__image_decoder__num_animation_loops( - ctx->dec), NULL); - cairo_surface_set_user_data(surface, &fiv_io_key_frame_duration, - (void *) (intptr_t) (wuffs_base__frame_config__duration(&fc) / - WUFFS_BASE__FLICKS_PER_MILLISECOND), NULL); + image->loops = wuffs_base__image_decoder__num_animation_loops(ctx->dec); + image->frame_duration = wuffs_base__frame_config__duration(&fc) / + WUFFS_BASE__FLICKS_PER_MILLISECOND; - cairo_surface_set_user_data( - surface, &fiv_io_key_frame_previous, ctx->result_tail, NULL); + image->frame_previous = ctx->result_tail; if (ctx->result_tail) - cairo_surface_set_user_data(ctx->result_tail, &fiv_io_key_frame_next, - surface, (cairo_destroy_func_t) cairo_surface_destroy); + ctx->result_tail->frame_next = image; else - ctx->result = surface; + ctx->result = image; - ctx->result_tail = surface; + ctx->result_tail = image; ctx->last_fc = fc; g_free(targetbuf); return wuffs_base__status__is_ok(&status); fail: - cairo_surface_destroy(surface); - g_clear_pointer(&ctx->result, cairo_surface_destroy); + g_clear_pointer(&image, fiv_io_image_unref); + g_clear_pointer(&ctx->result, fiv_io_image_unref); ctx->result_tail = NULL; g_free(targetbuf); return false; @@ -781,12 +588,13 @@ fail: // https://github.com/google/wuffs/blob/main/example/gifplayer/gifplayer.c // is pure C, and a good reference. I can't use the auxiliary libraries, // since they depend on C++, which is undesirable. -static cairo_surface_t * +static FivIoImage * open_wuffs(wuffs_base__image_decoder *dec, wuffs_base__io_buffer src, const FivIoOpenContext *ioctx, GError **error) { struct load_wuffs_frame_context ctx = { - .dec = dec, .src = &src, .target = ioctx->screen_profile}; + .dec = dec, .src = &src, + .cmm = ioctx->cmm, .target = ioctx->screen_profile}; // TODO(p): PNG text chunks, like we do with PNG thumbnails. // TODO(p): See if something could and should be done about @@ -870,9 +678,11 @@ open_wuffs(wuffs_base__image_decoder *dec, wuffs_base__io_buffer src, // TODO(p): Improve our simplistic PNG handling of: gAMA, cHRM, sRGB. if (ctx.target) { if (ctx.meta_iccp) - ctx.source = fiv_io_profile_new_from_bytes(ctx.meta_iccp); + ctx.source = fiv_io_cmm_get_profile_from_bytes( + ctx.cmm, ctx.meta_iccp); else if (isfinite(gamma) && gamma > 0) - ctx.source = fiv_io_profile_new_sRGB_gamma(gamma); + ctx.source = fiv_io_cmm_get_profile_sRGB_gamma( + ctx.cmm, gamma); } // Wuffs maps tRNS to BGRA in `decoder.decode_trns?`, we should be fine. @@ -943,8 +753,7 @@ open_wuffs(wuffs_base__image_decoder *dec, wuffs_base__io_buffer src, // Wrap the chain around, since our caller receives only one pointer. if (ctx.result) - cairo_surface_set_user_data( - ctx.result, &fiv_io_key_frame_previous, ctx.result_tail, NULL); + ctx.result->frame_previous = ctx.result_tail; fail: free(ctx.workbuf.ptr); @@ -955,7 +764,7 @@ fail: return ctx.result; } -static cairo_surface_t * +static FivIoImage * open_wuffs_using(wuffs_base__image_decoder *(*allocate)(), const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) { @@ -965,11 +774,11 @@ open_wuffs_using(wuffs_base__image_decoder *(*allocate)(), return NULL; } - cairo_surface_t *surface = + FivIoImage *image = open_wuffs(dec, wuffs_base__ptr_u8__reader((uint8_t *) data, len, TRUE), ctx, error); free(dec); - return surface; + return image; } // --- Wuffs for PNG thumbnails ------------------------------------------------ @@ -1003,7 +812,7 @@ pull_metadata_kvp(wuffs_png__decoder *dec, wuffs_base__io_buffer *src, } // An uncomplicated variant of fiv_io_open(), might be up for refactoring. -cairo_surface_t * +FivIoImage * fiv_io_open_png_thumbnail(const char *path, GError **error) { wuffs_png__decoder dec = {}; @@ -1026,7 +835,7 @@ fiv_io_open_png_thumbnail(const char *path, GError **error) wuffs_base__image_config cfg = {}; wuffs_base__slice_u8 workbuf = {}; - cairo_surface_t *surface = NULL; + FivIoImage *image = NULL; bool success = false; GHashTable *texts = @@ -1068,23 +877,19 @@ fiv_io_open_png_thumbnail(const char *path, GError **error) } } - surface = cairo_image_surface_create( + image = fiv_io_image_new( wuffs_base__image_config__first_frame_is_opaque(&cfg) ? CAIRO_FORMAT_RGB24 : CAIRO_FORMAT_ARGB32, width, height); - - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); + if (!image) { + set_error(error, "image allocation failure"); goto fail; } wuffs_base__pixel_buffer pb = {}; status = wuffs_base__pixel_buffer__set_from_slice(&pb, &cfg.pixcfg, - wuffs_base__make_slice_u8(cairo_image_surface_get_data(surface), - cairo_image_surface_get_stride(surface) * - cairo_image_surface_get_height(surface))); + wuffs_base__make_slice_u8(image->data, image->stride * image->height)); if (!wuffs_base__status__is_ok(&status)) { set_error(error, wuffs_base__status__message(&status)); goto fail; @@ -1117,49 +922,220 @@ fiv_io_open_png_thumbnail(const char *path, GError **error) g_assert(key == NULL); - cairo_surface_mark_dirty(surface); - cairo_surface_set_user_data(surface, &fiv_io_key_text, - g_hash_table_ref(texts), (cairo_destroy_func_t) g_hash_table_unref); + image->text = g_hash_table_ref(texts); success = true; fail: if (!success) - g_clear_pointer(&surface, cairo_surface_destroy); + g_clear_pointer(&image, fiv_io_image_unref); free(workbuf.ptr); g_free(data); g_hash_table_unref(texts); - return surface; + return image; +} + +// --- Multi-Picture Format ---------------------------------------------------- + +static uint32_t +parse_mpf_mpentry(const uint8_t *p, const struct tiffer *T) +{ + uint32_t attrs = T->un->u32(p); + uint32_t offset = T->un->u32(p + 8); + + enum { + TypeBaselineMPPrimaryImage = 0x030000, + TypeLargeThumbnailVGA = 0x010001, + TypeLargeThumbnailFullHD = 0x010002, + TypeMultiFrameImagePanorama = 0x020001, + TypeMultiFrameImageDisparity = 0x020002, + TypeMultiFrameImageMultiAngle = 0x020003, + TypeUndefined = 0x000000, + }; + switch (attrs & 0xFFFFFF) { + case TypeLargeThumbnailVGA: + case TypeLargeThumbnailFullHD: + // Wasted cycles. + case TypeUndefined: + // Apple uses this for HDR and depth maps (same and lower resolution). + // TODO(p): It would be nice to be able to view them. + return 0; + } + + // Don't report non-JPEGs, even though they're unlikely. + if (((attrs >> 24) & 0x7) != 0) + return 0; + + return offset; +} + +static uint32_t * +parse_mpf_index_entries(const struct tiffer *T, struct tiffer_entry *entry) +{ + uint32_t count = entry->remaining_count / 16; + uint32_t *offsets = g_malloc0_n(sizeof *offsets, count + 1), *out = offsets; + for (uint32_t i = 0; i < count; i++) { + // 5.2.3.3.3. Individual Image Data Offset + uint32_t offset = parse_mpf_mpentry(entry->p + i * 16, T); + if (offset) + *out++ = offset; + } + return offsets; +} + +static uint32_t * +parse_mpf_index_ifd(struct tiffer *T) +{ + struct tiffer_entry entry = {}; + while (tiffer_next_entry(T, &entry)) { + // 5.2.3.3. MP Entry + if (entry.tag == MPF_MPEntry && entry.type == TIFFER_UNDEFINED && + !(entry.remaining_count % 16)) { + return parse_mpf_index_entries(T, &entry); + } + } + return NULL; +} + +static bool +parse_mpf( + GPtrArray *individuals, const uint8_t *mpf, size_t len, size_t total_len) +{ + struct tiffer T; + if (!tiffer_init(&T, mpf, len) || !tiffer_next_ifd(&T)) + return false; + + // First image: IFD0 is Index IFD, any IFD1 is Attribute IFD. + // Other images: IFD0 is Attribute IFD, there is no Index IFD. + uint32_t *offsets = parse_mpf_index_ifd(&T); + if (offsets) { + for (const uint32_t *o = offsets; *o; o++) + if (*o <= total_len) + g_ptr_array_add(individuals, (gpointer) mpf + *o); + free(offsets); + } + return true; } // --- JPEG -------------------------------------------------------------------- -static GBytes * -parse_jpeg_metadata(cairo_surface_t *surface, const char *data, gsize len) +struct exif_profile { + double whitepoint[2]; ///< TIFF_WhitePoint + double primaries[6]; ///< TIFF_PrimaryChromaticities + enum Exif_ColorSpace colorspace; ///< Exif_ColorSpace + double gamma; ///< Exif_Gamma + + bool have_whitepoint; + bool have_primaries; + bool have_colorspace; + bool have_gamma; +}; + +static bool +parse_exif_profile_reals( + const struct tiffer *T, struct tiffer_entry *entry, double *out) +{ + while (tiffer_real(T, entry, out++)) + if (!tiffer_next_value(entry)) + return false; + return true; +} + +static void +parse_exif_profile_subifd( + struct exif_profile *params, const struct tiffer *T, uint32_t offset) +{ + struct tiffer subT = {}; + if (!tiffer_subifd(T, offset, &subT)) + return; + + struct tiffer_entry entry = {}; + while (tiffer_next_entry(&subT, &entry)) { + int64_t value = 0; + if (G_UNLIKELY(entry.tag == Exif_ColorSpace) && + entry.type == TIFFER_SHORT && entry.remaining_count == 1 && + tiffer_integer(&subT, &entry, &value)) { + params->have_colorspace = true; + params->colorspace = value; + } else if (G_UNLIKELY(entry.tag == Exif_Gamma) && + entry.type == TIFFER_RATIONAL && entry.remaining_count == 1 && + tiffer_real(&subT, &entry, ¶ms->gamma)) { + params->have_gamma = true; + } + } +} + +static FivIoProfile * +parse_exif_profile(FivIoCmm *cmm, const void *data, size_t len) +{ + struct tiffer T = {}; + if (!tiffer_init(&T, (const uint8_t *) data, len) || !tiffer_next_ifd(&T)) + return NULL; + + struct exif_profile params = {}; + struct tiffer_entry entry = {}; + while (tiffer_next_entry(&T, &entry)) { + int64_t offset = 0; + if (G_UNLIKELY(entry.tag == TIFF_ExifIFDPointer) && + entry.type == TIFFER_LONG && entry.remaining_count == 1 && + tiffer_integer(&T, &entry, &offset) && + offset >= 0 && offset <= UINT32_MAX) { + parse_exif_profile_subifd(¶ms, &T, offset); + } else if (G_UNLIKELY(entry.tag == TIFF_WhitePoint) && + entry.type == TIFFER_RATIONAL && + entry.remaining_count == G_N_ELEMENTS(params.whitepoint)) { + params.have_whitepoint = + parse_exif_profile_reals(&T, &entry, params.whitepoint); + } else if (G_UNLIKELY(entry.tag == TIFF_PrimaryChromaticities) && + entry.type == TIFFER_RATIONAL && + entry.remaining_count == G_N_ELEMENTS(params.primaries)) { + params.have_primaries = + parse_exif_profile_reals(&T, &entry, params.primaries); + } + } + if (!params.have_colorspace) + return NULL; + + // If sRGB is claimed, assume all parameters are standard. + if (params.colorspace == Exif_ColorSpace_sRGB) + return fiv_io_cmm_get_profile_sRGB(cmm); + + // AdobeRGB Nikon JPEGs provide all of these. + if (params.colorspace != Exif_ColorSpace_Uncalibrated || + !params.have_gamma || + !params.have_whitepoint || + !params.have_primaries) + return NULL; + + return fiv_io_cmm_get_profile_parametric(cmm, + params.gamma, params.whitepoint, params.primaries); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct jpeg_metadata { + GByteArray *exif; ///< Exif buffer or NULL + GByteArray *icc; ///< ICC profile buffer or NULL + GPtrArray *mpf; ///< Multi-Picture Format or NULL + int width; ///< Image width + int height; ///< Image height +}; + +static void +parse_jpeg_metadata(const char *data, size_t len, struct jpeg_metadata *meta) { // Because the JPEG file format is simple, just do it manually. // See: https://www.w3.org/Graphics/JPEG/itu-t81.pdf enum { - APP0 = 0xE0, - APP1, - APP2, - RST0 = 0xD0, - RST1, - RST2, - RST3, - RST4, - RST5, - RST6, - RST7, - SOI = 0xD8, - EOI = 0xD9, - SOS = 0xDA, TEM = 0x01, + SOF0 = 0xC0, SOF1, SOF2, SOF3, DHT, SOF5, SOF6, SOF7, + JPG, SOF9, SOF10, SOF11, DAC, SOF13, SOF14, SOF15, + RST0, RST1, RST2, RST3, RST4, RST5, RST6, RST7, + SOI, EOI, SOS, DQT, DNL, DRI, DHP, EXP, + APP0, APP1, APP2, APP3, APP4, APP5, APP6, APP7, }; - GByteArray *exif = g_byte_array_new(), *icc = g_byte_array_new(); int icc_sequence = 0, icc_done = FALSE; - const guint8 *p = (const guint8 *) data, *end = p + len; while (p + 3 < end && *p++ == 0xFF && *p != SOS && *p != EOI) { // The previous byte is a fill byte, restart. @@ -1188,155 +1164,127 @@ parse_jpeg_metadata(cairo_surface_t *surface, const char *data, gsize len) if (G_UNLIKELY((p += length) > end)) break; + switch (marker) { + case SOF0: + case SOF1: + case SOF2: + case SOF3: + case SOF5: + case SOF6: + case SOF7: + case SOF9: + case SOF10: + case SOF11: + case SOF13: + case SOF14: + case SOF15: + if (length >= 5) { + meta->width = (payload[3] << 8) + payload[4]; + meta->height = (payload[1] << 8) + payload[2]; + } + } + // https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf 4.7.2 // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 // Not checking the padding byte is intentional. - if (marker == APP1 && p - payload >= 6 && - !memcmp(payload, "Exif\0", 5) && !exif->len) { + // XXX: Thumbnails may in practice overflow into follow-up segments. + if (meta->exif && marker == APP1 && p - payload >= 6 && + !memcmp(payload, "Exif\0", 5) && !meta->exif->len) { payload += 6; - g_byte_array_append(exif, payload, p - payload); + g_byte_array_append(meta->exif, payload, p - payload); } // https://www.color.org/specification/ICC1v43_2010-12.pdf B.4 - if (marker == APP2 && p - payload >= 14 && + if (meta->icc && marker == APP2 && p - payload >= 14 && !memcmp(payload, "ICC_PROFILE\0", 12) && !icc_done && payload[12] == ++icc_sequence && payload[13] >= payload[12]) { payload += 14; - g_byte_array_append(icc, payload, p - payload); + g_byte_array_append(meta->icc, payload, p - payload); icc_done = payload[-1] == icc_sequence; } + // CIPA DC-007-2021 (Multi-Picture Format) 5.2 + // https://www.cipa.jp/e/std/std-sec.html + if (meta->mpf && marker == APP2 && p - payload >= 8 && + !memcmp(payload, "MPF\0", 4) && !meta->mpf->len) { + payload += 4; + parse_mpf(meta->mpf, payload, p - payload, end - payload); + } + // TODO(p): Extract the main XMP segment. } - if (exif->len) - cairo_surface_set_user_data(surface, &fiv_io_key_exif, - g_byte_array_free_to_bytes(exif), - (cairo_destroy_func_t) g_bytes_unref); + if (meta->icc && !icc_done) + g_byte_array_set_size(meta->icc, 0); +} + +static FivIoImage *open_libjpeg_turbo( + const char *data, gsize len, const FivIoOpenContext *ctx, GError **error); + +static void +load_jpeg_finalize(FivIoImage *image, bool cmyk, + const FivIoOpenContext *ctx, const char *data, size_t len) +{ + struct jpeg_metadata meta = { + .exif = g_byte_array_new(), + .icc = g_byte_array_new(), + .mpf = g_ptr_array_new(), + }; + + parse_jpeg_metadata(data, len, &meta); + + if (!ctx->first_frame_only) { + // XXX: This is ugly, as it relies on just the first individual image + // having any follow-up entries (as it should be). + FivIoImage *image_tail = image; + for (guint i = 0; i < meta.mpf->len; i++) { + const char *jpeg = meta.mpf->pdata[i]; + GError *error = NULL; + if (!try_append_page( + open_libjpeg_turbo(jpeg, len - (jpeg - data), ctx, &error), + &image, &image_tail)) { + add_warning(ctx, "MPF image %d: %s", i + 2, error->message); + g_error_free(error); + } + } + } + g_ptr_array_free(meta.mpf, TRUE); + + if (meta.exif->len) + image->exif = g_byte_array_free_to_bytes(meta.exif); else - g_byte_array_free(exif, TRUE); + g_byte_array_free(meta.exif, TRUE); GBytes *icc_profile = NULL; - if (icc_done) - cairo_surface_set_user_data(surface, &fiv_io_key_icc, - (icc_profile = g_byte_array_free_to_bytes(icc)), - (cairo_destroy_func_t) g_bytes_unref); + if (meta.icc->len) + image->icc = icc_profile = g_byte_array_free_to_bytes(meta.icc); else - g_byte_array_free(icc, TRUE); - return icc_profile; -} + g_byte_array_free(meta.icc, TRUE); -static void -load_jpeg_finalize(cairo_surface_t *surface, bool cmyk, - FivIoProfile destination, const char *data, size_t len) -{ - GBytes *icc_profile = parse_jpeg_metadata(surface, data, len); - FivIoProfile source = NULL; - if (icc_profile) - source = fiv_io_profile_new( + FivIoProfile *source = NULL; + if (icc_profile && ctx->cmm) + source = fiv_io_cmm_get_profile(ctx->cmm, g_bytes_get_data(icc_profile, NULL), g_bytes_get_size(icc_profile)); + else if (image->exif && ctx->cmm) + source = parse_exif_profile(ctx->cmm, + g_bytes_get_data(image->exif, NULL), g_bytes_get_size(image->exif)); if (cmyk) - fiv_io_profile_cmyk(surface, source, destination); + fiv_io_cmm_cmyk(ctx->cmm, image, source, ctx->screen_profile); else - fiv_io_profile_xrgb32(surface, source, destination); + fiv_io_cmm_any(ctx->cmm, image, source, ctx->screen_profile); if (source) fiv_io_profile_free(source); - - // Pixel data has been written, need to let Cairo know. - cairo_surface_mark_dirty(surface); -} - -static cairo_surface_t * -open_libjpeg_turbo( - const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) -{ - // Note that there doesn't seem to be much of a point in using this - // simplified API anymore, because JPEG-QS needs the original libjpeg API. - // It's just more or less duplicated code which won't compile with - // the slow version of the library. - tjhandle dec = tjInitDecompress(); - if (!dec) { - set_error(error, tjGetErrorStr2(dec)); - return NULL; - } - - int width = 0, height = 0, subsampling = TJSAMP_444, colorspace = TJCS_RGB; - if (tjDecompressHeader3(dec, (const unsigned char *) data, len, - &width, &height, &subsampling, &colorspace)) { - set_error(error, tjGetErrorStr2(dec)); - tjDestroy(dec); - return NULL; - } - - bool use_cmyk = colorspace == TJCS_CMYK || colorspace == TJCS_YCCK; - int pixel_format = use_cmyk - ? TJPF_CMYK - : (G_BYTE_ORDER == G_LITTLE_ENDIAN ? TJPF_BGRX : TJPF_XRGB); - - // The limit of Cairo/pixman is 32767. but JPEG can go as high as 65535. - // Prevent Cairo from throwing an error, and make use of libjpeg's scaling. - // gdk-pixbuf circumvents this check, producing unrenderable surfaces. - const int max = 32767; - - int nfs = 0; - tjscalingfactor *fs = tjGetScalingFactors(&nfs), f = {0, 1}; - if (fs && (width > max || height > max)) { - for (int i = 0; i < nfs; i++) { - if (TJSCALED(width, fs[i]) <= max && - TJSCALED(height, fs[i]) <= max && - fs[i].num * f.denom > f.num * fs[i].denom) - f = fs[i]; - } - - add_warning(ctx, - "the image is too large, and had to be scaled by %d/%d", - f.num, f.denom); - width = TJSCALED(width, f); - height = TJSCALED(height, f); - } - - cairo_surface_t *surface = - cairo_image_surface_create(CAIRO_FORMAT_RGB24, width, height); - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); - cairo_surface_destroy(surface); - tjDestroy(dec); - return NULL; - } - - // Starting to modify pixel data directly. Probably an unnecessary call. - cairo_surface_flush(surface); - - int stride = cairo_image_surface_get_stride(surface); - if (tjDecompress2(dec, (const unsigned char *) data, len, - cairo_image_surface_get_data(surface), width, stride, height, - pixel_format, TJFLAG_ACCURATEDCT)) { - if (tjGetErrorCode(dec) == TJERR_WARNING) { - add_warning(ctx, "%s", tjGetErrorStr2(dec)); - } else { - set_error(error, tjGetErrorStr2(dec)); - cairo_surface_destroy(surface); - tjDestroy(dec); - return NULL; - } - } - - load_jpeg_finalize(surface, use_cmyk, ctx->screen_profile, data, len); - tjDestroy(dec); - return surface; } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -#ifdef HAVE_JPEG_QS - struct libjpeg_error_mgr { struct jpeg_error_mgr pub; jmp_buf buf; GError **error; + const FivIoOpenContext *ctx; }; static void @@ -1349,17 +1297,27 @@ libjpeg_error_exit(j_common_ptr cinfo) longjmp(err->buf, 1); } -static cairo_surface_t * -open_libjpeg_enhanced( - const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) +static void +libjpeg_output_message(j_common_ptr cinfo) +{ + struct libjpeg_error_mgr *err = (struct libjpeg_error_mgr *) cinfo->err; + char buf[JMSG_LENGTH_MAX] = ""; + (*cinfo->err->format_message)(cinfo, buf); + add_warning(err->ctx, "%s", buf); +} + +static FivIoImage * +load_libjpeg_turbo(const char *data, gsize len, const FivIoOpenContext *ctx, + void (*loop)(struct jpeg_decompress_struct *, JSAMPARRAY), GError **error) { - cairo_surface_t *volatile surface = NULL; + FivIoImage *volatile image = NULL; - struct libjpeg_error_mgr jerr = {.error = error}; + struct libjpeg_error_mgr jerr = {.error = error, .ctx = ctx}; struct jpeg_decompress_struct cinfo = {.err = jpeg_std_error(&jerr.pub)}; jerr.pub.error_exit = libjpeg_error_exit; + jerr.pub.output_message = libjpeg_output_message; if (setjmp(jerr.buf)) { - g_clear_pointer(&surface, cairo_surface_destroy); + g_clear_pointer(&image, fiv_io_image_unref); jpeg_destroy_decompress(&cinfo); return NULL; } @@ -1367,6 +1325,8 @@ open_libjpeg_enhanced( jpeg_create_decompress(&cinfo); jpeg_mem_src(&cinfo, (const unsigned char *) data, len); (void) jpeg_read_header(&cinfo, true); + // TODO(p): With newer libjpeg-turbo, if cinfo.data_precision is 12 or 16, + // try to load it with higher precision. bool use_cmyk = cinfo.jpeg_color_space == JCS_CMYK || cinfo.jpeg_color_space == JCS_YCCK; @@ -1381,45 +1341,98 @@ open_libjpeg_enhanced( int width = cinfo.output_width; int height = cinfo.output_height; - surface = cairo_image_surface_create(CAIRO_FORMAT_RGB24, width, height); - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); + // The limit of Cairo/pixman is 32767. but JPEG can go as high as 65535. + // Prevent Cairo from throwing an error, and make use of libjpeg's scaling. + // gdk-pixbuf circumvents this check, producing unrenderable surfaces. + const int max = 32767; + + int nfs = 0; + tjscalingfactor *fs = tjGetScalingFactors(&nfs), f = {0, 1}; + if (fs && (width > max || height > max)) { + for (int i = 0; i < nfs; i++) { + if (TJSCALED(width, fs[i]) <= max && + TJSCALED(height, fs[i]) <= max && + fs[i].num * f.denom > f.num * fs[i].denom) + f = fs[i]; + } + + add_warning(ctx, + "the image is too large, and had to be scaled by %d/%d", + f.num, f.denom); + width = TJSCALED(width, f); + height = TJSCALED(height, f); + cinfo.scale_num = f.num; + cinfo.scale_denom = f.denom; + } + + image = fiv_io_image_new(CAIRO_FORMAT_RGB24, width, height); + if (!image) { + set_error(error, "image allocation failure"); longjmp(jerr.buf, 1); } - unsigned char *surface_data = cairo_image_surface_get_data(surface); - int surface_stride = cairo_image_surface_get_stride(surface); JSAMPARRAY lines = (*cinfo.mem->alloc_small)( (j_common_ptr) &cinfo, JPOOL_IMAGE, sizeof *lines * height); for (int i = 0; i < height; i++) - lines[i] = surface_data + i * surface_stride; + lines[i] = image->data + i * image->stride; + + // Slightly unfortunate generalization. + loop(&cinfo, lines); + + load_jpeg_finalize(image, use_cmyk, ctx, data, len); + jpeg_destroy_decompress(&cinfo); + return image; +} + +static void +load_libjpeg_simple( + struct jpeg_decompress_struct *cinfo, JSAMPARRAY lines) +{ + (void) jpeg_start_decompress(cinfo); + while (cinfo->output_scanline < cinfo->output_height) + (void) jpeg_read_scanlines(cinfo, lines + cinfo->output_scanline, + cinfo->output_height - cinfo->output_scanline); + (void) jpeg_finish_decompress(cinfo); +} + +#ifdef HAVE_JPEG_QS +static void +load_libjpeg_enhanced( + struct jpeg_decompress_struct *cinfo, JSAMPARRAY lines) +{ // Go for the maximum quality setting. jpegqs_control_t opts = { - .flags = JPEGQS_DIAGONALS | JPEGQS_JOINT_YUV | JPEGQS_UPSAMPLE_UV, + .flags = JPEGQS_DIAGONALS | JPEGQS_JOINT_YUV, .threads = g_get_num_processors(), .niter = 3, }; - (void) jpegqs_start_decompress(&cinfo, &opts); - while (cinfo.output_scanline < cinfo.output_height) - (void) jpeg_read_scanlines(&cinfo, lines + cinfo.output_scanline, - cinfo.output_height - cinfo.output_scanline); - if (cinfo.out_color_space == JCS_CMYK) - trivial_cmyk_to_host_byte_order_argb( - surface_data, cinfo.output_width * cinfo.output_height); - (void) jpegqs_finish_decompress(&cinfo); + // Waiting for https://github.com/ilyakurdyukov/jpeg-quantsmooth/issues/28 +#if LIBJPEG_TURBO_VERSION_NUMBER < 2001090 + opts.flags |= JPEGQS_UPSAMPLE_UV; +#endif - load_jpeg_finalize(surface, use_cmyk, ctx->screen_profile, data, len); - jpeg_destroy_decompress(&cinfo); - return surface; + (void) jpegqs_start_decompress(cinfo, &opts); + while (cinfo->output_scanline < cinfo->output_height) + (void) jpeg_read_scanlines(cinfo, lines + cinfo->output_scanline, + cinfo->output_height - cinfo->output_scanline); + (void) jpegqs_finish_decompress(cinfo); } #else -#define open_libjpeg_enhanced open_libjpeg_turbo +#define load_libjpeg_enhanced load_libjpeg_simple #endif +static FivIoImage * +open_libjpeg_turbo( + const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) +{ + return load_libjpeg_turbo(data, len, ctx, + ctx->enhance ? load_libjpeg_enhanced : load_libjpeg_simple, + error); +} + // --- WebP -------------------------------------------------------------------- static const char * @@ -1447,17 +1460,15 @@ load_libwebp_error(VP8StatusCode err) } } -static cairo_surface_t * +static FivIoImage * load_libwebp_nonanimated(WebPDecoderConfig *config, const WebPData *wd, const FivIoOpenContext *ctx, GError **error) { - cairo_surface_t *surface = cairo_image_surface_create( + FivIoImage *image = fiv_io_image_new( config->input.has_alpha ? CAIRO_FORMAT_ARGB32 : CAIRO_FORMAT_RGB24, config->input.width, config->input.height); - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); - cairo_surface_destroy(surface); + if (!image) { + set_error(error, "image allocation failure"); return NULL; } @@ -1466,10 +1477,9 @@ load_libwebp_nonanimated(WebPDecoderConfig *config, const WebPData *wd, config->output.width = config->input.width; config->output.height = config->input.height; config->output.is_external_memory = true; - config->output.u.RGBA.rgba = cairo_image_surface_get_data(surface); - config->output.u.RGBA.stride = cairo_image_surface_get_stride(surface); - config->output.u.RGBA.size = - config->output.u.RGBA.stride * cairo_image_surface_get_height(surface); + config->output.u.RGBA.rgba = image->data; + config->output.u.RGBA.stride = image->stride; + config->output.u.RGBA.size = config->output.u.RGBA.stride * image->height; bool premultiply = !ctx->screen_profile; if (G_BYTE_ORDER == G_LITTLE_ENDIAN) @@ -1480,43 +1490,34 @@ load_libwebp_nonanimated(WebPDecoderConfig *config, const WebPData *wd, WebPIDecoder *idec = WebPIDecode(NULL, 0, config); if (!idec) { set_error(error, "WebP decoding error"); - cairo_surface_destroy(surface); + fiv_io_image_unref(image); return NULL; } VP8StatusCode err = WebPIUpdate(idec, wd->bytes, wd->size); - cairo_surface_mark_dirty(surface); int x = 0, y = 0, w = 0, h = 0; (void) WebPIDecodedArea(idec, &x, &y, &w, &h); WebPIDelete(idec); if (err == VP8_STATUS_OK) - return surface; + return image; if (err != VP8_STATUS_SUSPENDED) { g_set_error(error, FIV_IO_ERROR, FIV_IO_ERROR_OPEN, "%s: %s", "WebP decoding error", load_libwebp_error(err)); - cairo_surface_destroy(surface); + fiv_io_image_unref(image); return NULL; } add_warning(ctx, "image file is truncated"); if (config->input.has_alpha) - return surface; + return image; // Always use transparent black, rather than opaque black. - cairo_surface_t *masked = cairo_image_surface_create( - CAIRO_FORMAT_ARGB32, config->input.width, config->input.height); - cairo_t *cr = cairo_create(masked); - cairo_set_source_surface(cr, surface, 0, 0); - cairo_rectangle(cr, x, y, w, h); - cairo_clip(cr); - cairo_paint(cr); - cairo_destroy(cr); - cairo_surface_destroy(surface); - return masked; + image->format = CAIRO_FORMAT_ARGB32; + return image; } -static cairo_surface_t * +static FivIoImage * load_libwebp_frame(WebPAnimDecoder *dec, const WebPAnimInfo *info, int *last_timestamp, GError **error) { @@ -1527,38 +1528,39 @@ load_libwebp_frame(WebPAnimDecoder *dec, const WebPAnimInfo *info, return NULL; } - bool is_opaque = (info->bgcolor & 0xFF) == 0xFF; uint64_t area = info->canvas_width * info->canvas_height; - cairo_surface_t *surface = cairo_image_surface_create( - is_opaque ? CAIRO_FORMAT_RGB24 : CAIRO_FORMAT_ARGB32, + FivIoImage *image = fiv_io_image_new(CAIRO_FORMAT_RGB24, info->canvas_width, info->canvas_height); - - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); - cairo_surface_destroy(surface); + if (!image) { + set_error(error, "image allocation failure"); return NULL; } - uint32_t *dst = (uint32_t *) cairo_image_surface_get_data(surface); + uint32_t *dst = (uint32_t *) image->data; if (G_BYTE_ORDER == G_LITTLE_ENDIAN) { memcpy(dst, buf, area * sizeof *dst); } else { - uint32_t *src = (uint32_t *) buf; - for (uint64_t i = 0; i < area; i++) - *dst++ = GUINT32_FROM_LE(*src++); + const uint32_t *src = (const uint32_t *) buf; + for (uint64_t i = 0; i < area; i++) { + uint32_t value = *src++; + *dst++ = GUINT32_FROM_LE(value); + } } - cairo_surface_mark_dirty(surface); + // info->bgcolor is not reliable. + for (const uint32_t *p = dst, *end = dst + area; p < end; p++) + if ((~*p & 0xff000000)) { + image->format = CAIRO_FORMAT_ARGB32; + break; + } // This API is confusing and awkward. - cairo_surface_set_user_data(surface, &fiv_io_key_frame_duration, - (void *) (intptr_t) (timestamp - *last_timestamp), NULL); + image->frame_duration = timestamp - *last_timestamp; *last_timestamp = timestamp; - return surface; + return image; } -static cairo_surface_t * +static FivIoImage * load_libwebp_animated( const WebPData *wd, const FivIoOpenContext *ctx, GError **error) { @@ -1572,7 +1574,7 @@ load_libwebp_animated( WebPAnimDecoder *dec = WebPAnimDecoderNew(wd, &options); WebPAnimDecoderGetInfo(dec, &info); - cairo_surface_t *frames = NULL, *frames_tail = NULL; + FivIoImage *frames = NULL, *frames_tail = NULL; if (info.canvas_width > INT_MAX || info.canvas_height > INT_MAX) { set_error(error, "image dimensions overflow"); goto fail; @@ -1580,30 +1582,27 @@ load_libwebp_animated( int last_timestamp = 0; while (WebPAnimDecoderHasMoreFrames(dec)) { - cairo_surface_t *surface = + FivIoImage *image = load_libwebp_frame(dec, &info, &last_timestamp, error); - if (!surface) { - g_clear_pointer(&frames, cairo_surface_destroy); + if (!image) { + g_clear_pointer(&frames, fiv_io_image_unref); goto fail; } if (frames_tail) - cairo_surface_set_user_data(frames_tail, &fiv_io_key_frame_next, - surface, (cairo_destroy_func_t) cairo_surface_destroy); + frames_tail->frame_next = image; else - frames = surface; + frames = image; - cairo_surface_set_user_data( - surface, &fiv_io_key_frame_previous, frames_tail, NULL); - frames_tail = surface; + image->frame_previous = frames_tail; + frames_tail = image; } if (frames) { - cairo_surface_set_user_data( - frames, &fiv_io_key_frame_previous, frames_tail, NULL); + frames->frame_previous = frames_tail; } else { set_error(error, "the animation has no frames"); - g_clear_pointer(&frames, cairo_surface_destroy); + g_clear_pointer(&frames, fiv_io_image_unref); } fail: @@ -1611,7 +1610,7 @@ fail: return frames; } -static cairo_surface_t * +static FivIoImage * open_libwebp( const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) { @@ -1631,7 +1630,7 @@ open_libwebp( return NULL; } - cairo_surface_t *result = config.input.has_animation + FivIoImage *result = config.input.has_animation ? load_libwebp_animated(&wd, ctx, error) : load_libwebp_nonanimated(&config, &wd, ctx, error); if (!result) @@ -1646,90 +1645,321 @@ open_libwebp( } // Releasing the demux chunk iterator is actually a no-op. - // TODO(p): Avoid copy-pasting the chunk transfer code. WebPChunkIterator chunk_iter = {}; uint32_t flags = WebPDemuxGetI(demux, WEBP_FF_FORMAT_FLAGS); if ((flags & EXIF_FLAG) && WebPDemuxGetChunk(demux, "EXIF", 1, &chunk_iter)) { - cairo_surface_set_user_data(result, &fiv_io_key_exif, - g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size), - (cairo_destroy_func_t) g_bytes_unref); + result->exif = + g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size); WebPDemuxReleaseChunkIterator(&chunk_iter); } if ((flags & ICCP_FLAG) && WebPDemuxGetChunk(demux, "ICCP", 1, &chunk_iter)) { - cairo_surface_set_user_data(result, &fiv_io_key_icc, - g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size), - (cairo_destroy_func_t) g_bytes_unref); + result->icc = + g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size); WebPDemuxReleaseChunkIterator(&chunk_iter); } if ((flags & XMP_FLAG) && WebPDemuxGetChunk(demux, "XMP ", 1, &chunk_iter)) { - cairo_surface_set_user_data(result, &fiv_io_key_xmp, - g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size), - (cairo_destroy_func_t) g_bytes_unref); + result->xmp = + g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size); WebPDemuxReleaseChunkIterator(&chunk_iter); } if (WebPDemuxGetChunk(demux, "THUM", 1, &chunk_iter)) { - cairo_surface_set_user_data(result, &fiv_io_key_thum, - g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size), - (cairo_destroy_func_t) g_bytes_unref); + result->thum = + g_bytes_new(chunk_iter.chunk.bytes, chunk_iter.chunk.size); WebPDemuxReleaseChunkIterator(&chunk_iter); } - if (flags & ANIMATION_FLAG) { - cairo_surface_set_user_data(result, &fiv_io_key_loops, - (void *) (uintptr_t) WebPDemuxGetI(demux, WEBP_FF_LOOP_COUNT), - NULL); - } + if (flags & ANIMATION_FLAG) + result->loops = WebPDemuxGetI(demux, WEBP_FF_LOOP_COUNT); WebPDemuxDelete(demux); - if (ctx->screen_profile) { - fiv_io_profile_xrgb32_page(result, ctx->screen_profile); - fiv_io_premultiply_argb32_page(result); - } + if (ctx->screen_profile) + fiv_io_cmm_argb32_premultiply_page( + ctx->cmm, result, ctx->screen_profile); fail: WebPFreeDecBuffer(&config.output); return result; } -// --- Optional dependencies --------------------------------------------------- +// --- TIFF/EP + DNG ----------------------------------------------------------- +// In Nikon NEF files, which claim to be TIFF/EP-compatible, IFD0 is a tiny +// uncompressed thumbnail with SubIFDs that, aside from raw sensor data, +// typically contain a nearly full-size JPEG preview. +// +// LibRaw takes too long a time to render something that will never be as good +// as that large preview--e.g., due to exposure correction or denoising. +// While since version 0.21.0 the library provides an API that would allow us +// to extract the JPEG, a little bit of custom processing won't hurt either. +// TODO(p): Though it can also extract thumbnails from many more formats, +// so maybe keep this code as a fallback for old or missing LibRaw. +// +// Note that libtiff can only read the horrible IFD0 thumbnail. +// (TIFFSetSubDirectory() requires an ImageLength tag that's missing from JPEG +// SubIFDs, and TIFFReadCustomDirectory() takes a privately defined struct that +// may not be omitted.) -#ifdef HAVE_LIBRAW // --------------------------------------------------------- +static bool +tiffer_find(const struct tiffer *self, uint16_t tag, struct tiffer_entry *entry) +{ + // Note that we could employ binary search, because tags must be ordered: + // - TIFF 6.0: Sort Order + // - ISO/DIS 12234-2: 4.1.2, 5.1 + // - CIPA DC-007-2009 (Multi-Picture Format): 5.2.3., 5.2.4. + // - CIPA DC-008-2019 (Exif 2.32): 4.6.2. + // However, it doesn't seem to warrant the ugly code. + struct tiffer T = *self; + while (tiffer_next_entry(&T, entry)) { + if (entry->tag == tag) + return true; + } + *entry = (struct tiffer_entry) {}; + return false; +} -static cairo_surface_t * -open_libraw(const char *data, gsize len, GError **error) +static bool +tiffer_find_integer(const struct tiffer *self, uint16_t tag, int64_t *i) { - // https://github.com/LibRaw/LibRaw/issues/418 - libraw_data_t *iprc = libraw_init( - LIBRAW_OPIONS_NO_MEMERR_CALLBACK | LIBRAW_OPIONS_NO_DATAERR_CALLBACK); - if (!iprc) { - set_error(error, "failed to obtain a LibRaw handle"); + struct tiffer_entry entry = {}; + return tiffer_find(self, tag, &entry) && tiffer_integer(self, &entry, i); +} + +// In case of failure, an entry with a zero "remaining_count" is returned. +static struct tiffer_entry +tiff_ep_subifds_init(const struct tiffer *T) +{ + struct tiffer_entry entry = {}; + (void) tiffer_find(T, TIFF_SubIFDs, &entry); + return entry; +} + +static bool +tiff_ep_subifds_next( + const struct tiffer *T, struct tiffer_entry *subifds, struct tiffer *subT) +{ + // XXX: Except for a zero "remaining_count", all conditions are errors, + // and should perhaps be reported. + int64_t offset = 0; + if (!tiffer_integer(T, subifds, &offset) || + offset < 0 || offset > UINT32_MAX || !tiffer_subifd(T, offset, subT)) + return false; + + (void) tiffer_next_value(subifds); + return true; +} + +static bool +tiff_ep_find_main(const struct tiffer *T, struct tiffer *outputT) +{ + // This is a mandatory field. + int64_t type = 0; + if (!tiffer_find_integer(T, TIFF_NewSubfileType, &type)) + return false; + + // This is the main image. + // (See DNG rather than ISO/DIS 12234-2 for values.) + if (type == 0) { + *outputT = *T; + return true; + } + + struct tiffer_entry subifds = tiff_ep_subifds_init(T); + struct tiffer subT = {}; + while (tiff_ep_subifds_next(T, &subifds, &subT)) + if (tiff_ep_find_main(&subT, outputT)) + return true; + return false; +} + +struct tiff_ep_jpeg { + const uint8_t *jpeg; ///< JPEG data stream + size_t jpeg_length; ///< JPEG data stream length + int64_t pixels; ///< Number of pixels in the JPEG +}; + +static void +tiff_ep_find_jpeg_evaluate(const struct tiffer *T, struct tiff_ep_jpeg *out) +{ + // This is a mandatory field. + int64_t compression = 0; + if (!tiffer_find_integer(T, TIFF_Compression, &compression)) + return; + + uint16_t tag_pointer = 0, tag_length = 0; + switch (compression) { + // This is how Exif specifies it, which doesn't follow TIFF 6.0. + case TIFF_Compression_JPEG: + tag_pointer = TIFF_JPEGInterchangeFormat; + tag_length = TIFF_JPEGInterchangeFormatLength; + break; + // Theoretically, there may be more strips, but this is not expected. + case TIFF_Compression_JPEGDatastream: + tag_pointer = TIFF_StripOffsets; + tag_length = TIFF_StripByteCounts; + break; + default: + return; + } + + int64_t ipointer = 0, ilength = 0; + if (!tiffer_find_integer(T, tag_pointer, &ipointer) || ipointer <= 0 || + !tiffer_find_integer(T, tag_length, &ilength) || ilength <= 0 || + ipointer > T->end - T->begin || + T->end - T->begin - ipointer < ilength) + return; + + // Note that to get the largest JPEG, + // we don't need to descend into Exif thumbnails. + // TODO(p): Consider DNG 1.2.0.0 PreviewColorSpace. + // But first, try to find some real-world files with it. + const uint8_t *jpeg = T->begin + ipointer; + size_t jpeg_length = ilength; + + struct jpeg_metadata meta = {}; + parse_jpeg_metadata((const char *) jpeg, jpeg_length, &meta); + int64_t pixels = meta.width * meta.height; + if (pixels > out->pixels) { + out->jpeg = jpeg; + out->jpeg_length = jpeg_length; + out->pixels = pixels; + } +} + +static bool +tiff_ep_find_jpeg(const struct tiffer *T, struct tiff_ep_jpeg *out) +{ + // This is a mandatory field. + int64_t type = 0; + if (!tiffer_find_integer(T, TIFF_NewSubfileType, &type)) + return false; + + // This is a thumbnail of the main image. + // (See DNG rather than ISO/DIS 12234-2 for values.) + if (type == 1) + tiff_ep_find_jpeg_evaluate(T, out); + + struct tiffer_entry subifds = tiff_ep_subifds_init(T); + struct tiffer subT = {}; + while (tiff_ep_subifds_next(T, &subifds, &subT)) + if (!tiff_ep_find_jpeg(&subT, out)) + return false; + return true; +} + +static FivIoImage * +load_tiff_ep( + const struct tiffer *T, const FivIoOpenContext *ctx, GError **error) +{ + // ISO/DIS 12234-2 is a fuck-up that says this should be in "IFD0", + // but it might have intended to say "all top-level IFDs". + // The DNG specification shares the same problem. + // + // In any case, chained TIFFs are relatively rare. + struct tiffer_entry entry = {}; + bool is_tiffep = tiffer_find(T, TIFF_TIFF_EPStandardID, &entry) && + entry.type == TIFFER_BYTE && entry.remaining_count == 4 && + entry.p[0] == 1 && !entry.p[1] && !entry.p[2] && !entry.p[3]; + + // Apple ProRAW, e.g., does not claim TIFF/EP compatibility, + // but we should still be able to make sense of it. + bool is_supported_dng = tiffer_find(T, TIFF_DNGBackwardVersion, &entry) && + entry.type == TIFFER_BYTE && entry.remaining_count == 4 && + entry.p[0] == 1 && entry.p[1] <= 6 && !entry.p[2] && !entry.p[3]; + if (!is_tiffep && !is_supported_dng) { + set_error(error, "not a supported TIFF/EP or DNG image"); return NULL; } -#if 0 - // TODO(p): Consider setting this--the image is still likely to be - // rendered suboptimally, so why not make it faster. - iprc->params.half_size = 1; -#endif + struct tiffer fullT = {}; + if (!tiff_ep_find_main(T, &fullT)) { + set_error(error, "could not find a main image"); + return NULL; + } - // TODO(p): Check if we need to set anything for autorotation (sizes.flip). - iprc->params.use_camera_wb = 1; - iprc->params.output_color = 1; // sRGB, TODO(p): Is this used? - iprc->params.output_bps = 8; // This should be the default value. + int64_t width = 0, height = 0; + if (!tiffer_find_integer(&fullT, TIFF_ImageWidth, &width) || + !tiffer_find_integer(&fullT, TIFF_ImageLength, &height) || + width <= 0 || height <= 0) { + set_error(error, "missing or invalid main image dimensions"); + return NULL; + } - int err = 0; - if ((err = libraw_open_buffer(iprc, (void *) data, len))) { - set_error(error, libraw_strerror(err)); - libraw_close(iprc); + struct tiff_ep_jpeg out = {}; + if (!tiff_ep_find_jpeg(T, &out)) { + set_error(error, "error looking for a full-size JPEG preview"); + return NULL; + } + + // Nikon NEFs seem to generally have a preview above 99 percent, + // (though some of them may not even reach 50 percent). + // Be a bit more generous than that with our crop tolerance. + // TODO(p): Also take into account DNG DefaultCropSize, if present. + if (out.pixels / ((double) width * height) < 0.95) { + set_error(error, "could not find a large enough JPEG preview"); + return NULL; + } + + FivIoImage *image = open_libjpeg_turbo( + (const char *) out.jpeg, out.jpeg_length, ctx, error); + if (!image) + return NULL; + + // Note that Exif may override this later in fiv_io_open_from_data(). + // TODO(p): Try to use the Orientation field nearest to the target IFD. + // IFD0 just happens to be fine for Nikon NEF. + int64_t orientation = 0; + if (tiffer_find_integer(T, TIFF_Orientation, &orientation) && + orientation >= 1 && orientation <= 8) { + image->orientation = orientation; + } + + // XXX: AdobeRGB Nikon NEFs can only be distinguished by a ColorSpace tag + // from within their MakerNote. + return image; +} + +static FivIoImage * +open_tiff_ep( + const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) +{ + // -Wunused-function, we might want to give this its own compile unit. + (void) tiffer_real; + + struct tiffer T = {}; + if (!tiffer_init(&T, (const uint8_t *) data, len)) { + set_error(error, "not a TIFF file"); return NULL; } - // TODO(p): Do we need to check iprc->idata.raw_count? Maybe for TIFFs? + FivIoImage *result = NULL, *result_tail = NULL; + while (tiffer_next_ifd(&T)) { + if (!try_append_page( + load_tiff_ep(&T, ctx, error), &result, &result_tail)) { + g_clear_pointer(&result, fiv_io_image_unref); + return NULL; + } + if (ctx->first_frame_only) + break; + + // TODO(p): Try to adjust tiffer so that this isn't necessary. + struct tiffer_entry dummy = {}; + while (tiffer_next_entry(&T, &dummy)) + ; + } + return result; +} + +// --- Optional dependencies --------------------------------------------------- + +#ifdef HAVE_LIBRAW // --------------------------------------------------------- + +static FivIoImage * +load_libraw(libraw_data_t *iprc, GError **error) +{ + int err = 0; if ((err = libraw_unpack(iprc))) { set_error(error, libraw_strerror(err)); - libraw_close(iprc); return NULL; } @@ -1737,7 +1967,6 @@ open_libraw(const char *data, gsize len, GError **error) // TODO(p): I'm not sure when this is necessary or useful yet. if ((err = libraw_adjust_sizes_info_only(iprc))) { set_error(error, libraw_strerror(err)); - libraw_close(iprc); return NULL; } #endif @@ -1745,7 +1974,6 @@ open_libraw(const char *data, gsize len, GError **error) // TODO(p): Documentation says I should look at the code and do it myself. if ((err = libraw_dcraw_process(iprc))) { set_error(error, libraw_strerror(err)); - libraw_close(iprc); return NULL; } @@ -1754,7 +1982,6 @@ open_libraw(const char *data, gsize len, GError **error) libraw_processed_image_t *image = libraw_dcraw_make_mem_image(iprc, &err); if (!image) { set_error(error, libraw_strerror(err)); - libraw_close(iprc); return NULL; } @@ -1762,26 +1989,18 @@ open_libraw(const char *data, gsize len, GError **error) if (image->colors != 3 || image->bits != 8) { set_error(error, "unexpected number of colours, or bit depth"); libraw_dcraw_clear_mem(image); - libraw_close(iprc); return NULL; } - int width = image->width, height = image->height; - cairo_surface_t *surface = - cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height); - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); - cairo_surface_destroy(surface); + FivIoImage *I = + fiv_io_image_new(CAIRO_FORMAT_RGB24, image->width, image->height); + if (!I) { + set_error(error, "image allocation failure"); libraw_dcraw_clear_mem(image); - libraw_close(iprc); return NULL; } - // Starting to modify pixel data directly. Probably an unnecessary call. - cairo_surface_flush(surface); - - uint32_t *pixels = (uint32_t *) cairo_image_surface_get_data(surface); + uint32_t *pixels = (uint32_t *) I->data; unsigned char *p = image->data; for (ushort y = 0; y < image->height; y++) { for (ushort x = 0; x < image->width; x++) { @@ -1791,12 +2010,55 @@ open_libraw(const char *data, gsize len, GError **error) } } - // Pixel data has been written, need to let Cairo know. - cairo_surface_mark_dirty(surface); - libraw_dcraw_clear_mem(image); + return I; +} + +static FivIoImage * +open_libraw( + const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) +{ + // https://github.com/LibRaw/LibRaw/issues/418 + libraw_data_t *iprc = libraw_init( + LIBRAW_OPIONS_NO_MEMERR_CALLBACK | LIBRAW_OPIONS_NO_DATAERR_CALLBACK); + if (!iprc) { + set_error(error, "failed to obtain a LibRaw handle"); + return NULL; + } + + // TODO(p): Check if we need to set anything for autorotation (sizes.flip). + iprc->params.use_camera_wb = 1; + iprc->params.output_color = 1; // sRGB, TODO(p): Is this used? + iprc->params.output_bps = 8; // This should be the default value. + + int err = 0; + FivIoImage *result = NULL, *result_tail = NULL; + if ((err = libraw_open_buffer(iprc, (const void *) data, len))) { + set_error(error, libraw_strerror(err)); + goto out; + } + if (!try_append_page(load_libraw(iprc, error), &result, &result_tail) || + ctx->first_frame_only) + goto out; + + for (unsigned i = 1; i < iprc->idata.raw_count; i++) { + iprc->rawparams.shot_select = i; + + // This library is terrible, we need to start again. + if ((err = libraw_open_buffer(iprc, (const void *) data, len))) { + set_error(error, libraw_strerror(err)); + g_clear_pointer(&result, fiv_io_image_unref); + goto out; + } + if (!try_append_page(load_libraw(iprc, error), &result, &result_tail)) { + g_clear_pointer(&result, fiv_io_image_unref); + goto out; + } + } + +out: libraw_close(iprc); - return surface; + return fiv_io_cmm_finish(ctx->cmm, result, ctx->screen_profile); } #endif // HAVE_LIBRAW --------------------------------------------------------- @@ -1810,16 +2072,16 @@ typedef struct { } FivIoRenderClosureResvg; static void -load_resvg_destroy(void *closure) +load_resvg_destroy(FivIoRenderClosure *closure) { - FivIoRenderClosureResvg *self = closure; + FivIoRenderClosureResvg *self = (void *) closure; resvg_tree_destroy(self->tree); g_free(self); } -static cairo_surface_t * -load_resvg_render_internal( - FivIoRenderClosureResvg *self, double scale, GError **error) +static FivIoImage * +load_resvg_render_internal(FivIoRenderClosureResvg *self, double scale, + FivIoCmm *cmm, FivIoProfile *target, GError **error) { double w = ceil(self->width * scale), h = ceil(self->height * scale); if (w > SHRT_MAX || h > SHRT_MAX) { @@ -1827,38 +2089,37 @@ load_resvg_render_internal( return NULL; } - cairo_surface_t *surface = - cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h); - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); - cairo_surface_destroy(surface); + FivIoImage *image = fiv_io_image_new(CAIRO_FORMAT_ARGB32, w, h); + if (!image) { + set_error(error, "image allocation failure"); return NULL; } - uint32_t *pixels = (uint32_t *) cairo_image_surface_get_data(surface); + uint32_t *pixels = (uint32_t *) image->data; +#if RESVG_MAJOR_VERSION == 0 && RESVG_MINOR_VERSION < 33 resvg_fit_to fit_to = { scale == 1 ? RESVG_FIT_TO_TYPE_ORIGINAL : RESVG_FIT_TO_TYPE_ZOOM, scale}; resvg_render(self->tree, fit_to, resvg_transform_identity(), - cairo_image_surface_get_width(surface), - cairo_image_surface_get_height(surface), (char *) pixels); + image->width, image->height, (char *) pixels); +#else + resvg_render(self->tree, (resvg_transform) {.a = scale, .d = scale}, + image->width, image->height, (char *) pixels); +#endif - // TODO(p): Also apply colour management, we'll need to un-premultiply. for (int i = 0; i < w * h; i++) { uint32_t rgba = g_ntohl(pixels[i]); pixels[i] = rgba << 24 | rgba >> 8; } - - cairo_surface_mark_dirty(surface); - return surface; + return fiv_io_cmm_finish(cmm, image, target); } -static cairo_surface_t * -load_resvg_render(FivIoRenderClosure *closure, double scale) +static FivIoImage * +load_resvg_render(FivIoRenderClosure *closure, + FivIoCmm *cmm, FivIoProfile *target, double scale) { FivIoRenderClosureResvg *self = (FivIoRenderClosureResvg *) closure; - return load_resvg_render_internal(self, scale, NULL); + return load_resvg_render_internal(self, scale, cmm, target, NULL); } static const char * @@ -1882,7 +2143,7 @@ load_resvg_error(int err) } } -static cairo_surface_t * +static FivIoImage * open_resvg( const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) { @@ -1911,19 +2172,20 @@ open_resvg( FivIoRenderClosureResvg *closure = g_malloc0(sizeof *closure); closure->parent.render = load_resvg_render; + closure->parent.destroy = load_resvg_destroy; closure->tree = tree; closure->width = size.width; closure->height = size.height; - cairo_surface_t *surface = load_resvg_render_internal(closure, 1., error); - if (!surface) { - load_resvg_destroy(closure); + FivIoImage *image = load_resvg_render_internal( + closure, 1., ctx->cmm, ctx->screen_profile, error); + if (!image) { + load_resvg_destroy(&closure->parent); return NULL; } - cairo_surface_set_user_data( - surface, &fiv_io_key_render, closure, load_resvg_destroy); - return surface; + image->render = &closure->parent; + return image; } #endif // HAVE_RESVG ---------------------------------------------------------- @@ -1937,43 +2199,54 @@ typedef struct { } FivIoRenderClosureLibrsvg; static void -load_librsvg_destroy(void *closure) +load_librsvg_destroy(FivIoRenderClosure *closure) { - FivIoRenderClosureLibrsvg *self = closure; + FivIoRenderClosureLibrsvg *self = (void *) closure; g_object_unref(self->handle); g_free(self); } -static cairo_surface_t * -load_librsvg_render(FivIoRenderClosure *closure, double scale) +static FivIoImage * +load_librsvg_render_internal(FivIoRenderClosureLibrsvg *self, double scale, + FivIoCmm *cmm, FivIoProfile *target, GError **error) { - FivIoRenderClosureLibrsvg *self = (FivIoRenderClosureLibrsvg *) closure; RsvgRectangle viewport = {.x = 0, .y = 0, .width = self->width * scale, .height = self->height * scale}; - cairo_surface_t *surface = cairo_image_surface_create( + FivIoImage *image = fiv_io_image_new( CAIRO_FORMAT_ARGB32, ceil(viewport.width), ceil(viewport.height)); + if (!image) { + set_error(error, "image allocation failure"); + return NULL; + } - GError *error = NULL; + cairo_surface_t *surface = fiv_io_image_to_surface_noref(image); cairo_t *cr = cairo_create(surface); - (void) rsvg_handle_render_document(self->handle, cr, &viewport, &error); + cairo_surface_destroy(surface); + gboolean success = + rsvg_handle_render_document(self->handle, cr, &viewport, error); + cairo_status_t status = cairo_status(cr); cairo_destroy(cr); - if (error) { - g_debug("%s", error->message); - g_error_free(error); - cairo_surface_destroy(surface); + if (!success) { + fiv_io_image_unref(image); return NULL; } - - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - g_debug("%s", cairo_status_to_string(surface_status)); - cairo_surface_destroy(surface); + if (status != CAIRO_STATUS_SUCCESS) { + set_error(error, cairo_status_to_string(status)); + fiv_io_image_unref(image); return NULL; } - return surface; + return fiv_io_cmm_finish(cmm, image, target); } -static cairo_surface_t * +static FivIoImage * +load_librsvg_render(FivIoRenderClosure *closure, + FivIoCmm *cmm, FivIoProfile *target, double scale) +{ + FivIoRenderClosureLibrsvg *self = (FivIoRenderClosureLibrsvg *) closure; + return load_librsvg_render_internal(self, scale, cmm, target, NULL); +} + +static FivIoImage * open_librsvg( const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) { @@ -2010,32 +2283,24 @@ open_librsvg( h = viewbox.height; } - // librsvg rasterizes filters, so this method isn't fully appropriate. - // It might be worth removing altogether. - cairo_rectangle_t extents = { - .x = 0, .y = 0, .width = ceil(w), .height = ceil(h)}; - cairo_surface_t *surface = - cairo_recording_surface_create(CAIRO_CONTENT_COLOR_ALPHA, &extents); - - cairo_t *cr = cairo_create(surface); - RsvgRectangle viewport = {.x = 0, .y = 0, .width = w, .height = h}; - if (!rsvg_handle_render_document(handle, cr, &viewport, error)) { - cairo_surface_destroy(surface); - cairo_destroy(cr); - g_object_unref(handle); - return NULL; - } - - cairo_destroy(cr); - FivIoRenderClosureLibrsvg *closure = g_malloc0(sizeof *closure); closure->parent.render = load_librsvg_render; + closure->parent.destroy = load_librsvg_destroy; closure->handle = handle; closure->width = w; closure->height = h; - cairo_surface_set_user_data( - surface, &fiv_io_key_render, closure, load_librsvg_destroy); - return surface; + + // librsvg rasterizes filters, so rendering to a recording Cairo surface + // has been abandoned. + FivIoImage *image = load_librsvg_render_internal( + closure, 1., ctx->cmm, ctx->screen_profile, error); + if (!image) { + load_librsvg_destroy(&closure->parent); + return NULL; + } + + image->render = &closure->parent; + return image; } #endif // HAVE_LIBRSVG -------------------------------------------------------- @@ -2108,8 +2373,9 @@ static const XcursorFile fiv_io_xcursor_adaptor = { .seek = fiv_io_xcursor_seek, }; -static cairo_surface_t * -open_xcursor(const char *data, gsize len, GError **error) +static FivIoImage * +open_xcursor( + const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) { if (len > G_MAXLONG) { set_error(error, "size overflow"); @@ -2130,54 +2396,46 @@ open_xcursor(const char *data, gsize len, GError **error) } // Interpret cursors as animated pages. - cairo_surface_t *pages = NULL, *frames_head = NULL, *frames_tail = NULL; + FivIoImage *pages = NULL, *frames_head = NULL, *frames_tail = NULL; // XXX: Assuming that all "nominal sizes" have the same dimensions. XcursorDim last_nominal = -1; for (int i = 0; i < images->nimage; i++) { XcursorImage *image = images->images[i]; + FivIoImage *I = + fiv_io_image_new(CAIRO_FORMAT_ARGB32, image->width, image->height); + if (!I) { + add_warning(ctx, "%s", "image allocation failure"); + break; + } + // The library automatically byte swaps in _XcursorReadImage(). - cairo_surface_t *surface = cairo_image_surface_create_for_data( - (unsigned char *) image->pixels, CAIRO_FORMAT_ARGB32, - image->width, image->height, image->width * sizeof *image->pixels); - cairo_surface_set_user_data(surface, &fiv_io_key_frame_duration, - (void *) (intptr_t) image->delay, NULL); + memcpy(I->data, image->pixels, I->stride * I->height); + I->frame_duration = image->delay; if (pages && image->size == last_nominal) { - cairo_surface_set_user_data( - surface, &fiv_io_key_frame_previous, frames_tail, NULL); - cairo_surface_set_user_data(frames_tail, &fiv_io_key_frame_next, - surface, (cairo_destroy_func_t) cairo_surface_destroy); + I->frame_previous = frames_tail; + frames_tail->frame_next = I; } else if (frames_head) { - cairo_surface_set_user_data( - frames_head, &fiv_io_key_frame_previous, frames_tail, NULL); - - cairo_surface_set_user_data(frames_head, &fiv_io_key_page_next, - surface, (cairo_destroy_func_t) cairo_surface_destroy); - cairo_surface_set_user_data( - surface, &fiv_io_key_page_previous, frames_head, NULL); - frames_head = surface; + frames_head->frame_previous = frames_tail; + + frames_head->page_next = I; + I->page_previous = frames_head; + frames_head = I; } else { - pages = frames_head = surface; + pages = frames_head = I; } - frames_tail = surface; + frames_tail = I; last_nominal = image->size; } - if (!pages) { - XcursorImagesDestroy(images); + XcursorImagesDestroy(images); + if (!pages) return NULL; - } // Wrap around animations in the last page. - cairo_surface_set_user_data( - frames_head, &fiv_io_key_frame_previous, frames_tail, NULL); - - // There is no need to copy data, assign it to the surface. - static cairo_user_data_key_t key = {}; - cairo_surface_set_user_data( - pages, &key, images, (cairo_destroy_func_t) XcursorImagesDestroy); + frames_head->frame_previous = frames_tail; // Do not bother doing colour correction, there is no correct rendering. return pages; @@ -2186,10 +2444,10 @@ open_xcursor(const char *data, gsize len, GError **error) #endif // HAVE_XCURSOR -------------------------------------------------------- #ifdef HAVE_LIBHEIF //--------------------------------------------------------- -static cairo_surface_t * +static FivIoImage * load_libheif_image(struct heif_image_handle *handle, GError **error) { - cairo_surface_t *surface = NULL; + FivIoImage *I = NULL; int has_alpha = heif_image_handle_has_alpha_channel(handle); int bit_depth = heif_image_handle_get_luma_bits_per_pixel(handle); if (bit_depth < 0) { @@ -2212,13 +2470,10 @@ load_libheif_image(struct heif_image_handle *handle, GError **error) int w = heif_image_get_width(image, heif_channel_interleaved); int h = heif_image_get_height(image, heif_channel_interleaved); - surface = cairo_image_surface_create( + I = fiv_io_image_new( has_alpha ? CAIRO_FORMAT_ARGB32 : CAIRO_FORMAT_RGB24, w, h); - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); - cairo_surface_destroy(surface); - surface = NULL; + if (!I) { + set_error(error, "image allocation failure"); goto fail_process; } @@ -2226,11 +2481,8 @@ load_libheif_image(struct heif_image_handle *handle, GError **error) int src_stride = 0; const uint8_t *src = heif_image_get_plane_readonly( image, heif_channel_interleaved, &src_stride); - int dst_stride = cairo_image_surface_get_stride(surface); - const uint8_t *dst = cairo_image_surface_get_data(surface); - for (int y = 0; y < h; y++) { - uint32_t *dstp = (uint32_t *) (dst + dst_stride * y); + uint32_t *dstp = (uint32_t *) (I->data + I->stride * y); const uint32_t *srcp = (const uint32_t *) (src + src_stride * y); for (int x = 0; x < w; x++) { uint32_t rgba = g_ntohl(srcp[x]); @@ -2240,7 +2492,7 @@ load_libheif_image(struct heif_image_handle *handle, GError **error) // TODO(p): Test real behaviour on real transparent images. if (has_alpha && !heif_image_handle_is_premultiplied_alpha(handle)) - fiv_io_premultiply_argb32(surface); + fiv_io_premultiply_argb32(I); heif_item_id exif_id = 0; if (heif_image_handle_get_list_of_metadata_block_IDs( @@ -2252,9 +2504,7 @@ load_libheif_image(struct heif_image_handle *handle, GError **error) g_warning("%s", err.message); g_free(exif); } else { - cairo_surface_set_user_data(surface, &fiv_io_key_exif, - g_bytes_new_take(exif, exif_len), - (cairo_destroy_func_t) g_bytes_unref); + I->exif = g_bytes_new_take(exif, exif_len); } } @@ -2268,26 +2518,22 @@ load_libheif_image(struct heif_image_handle *handle, GError **error) g_warning("%s", err.message); g_free(icc); } else { - cairo_surface_set_user_data(surface, &fiv_io_key_icc, - g_bytes_new_take(icc, icc_len), - (cairo_destroy_func_t) g_bytes_unref); + I->icc = g_bytes_new_take(icc, icc_len); } } - cairo_surface_mark_dirty(surface); - fail_process: heif_image_release(image); fail_decode: heif_decoding_options_free(opts); fail: - return surface; + return I; } static void load_libheif_aux_images(const FivIoOpenContext *ioctx, - struct heif_image_handle *top, cairo_surface_t **result, - cairo_surface_t **result_tail) + struct heif_image_handle *top, + FivIoImage **result, FivIoImage **result_tail) { // Include the depth image, we have no special processing for it now. int filter = LIBHEIF_AUX_IMAGE_FILTER_OMIT_ALPHA; @@ -2317,14 +2563,14 @@ load_libheif_aux_images(const FivIoOpenContext *ioctx, g_free(ids); } -static cairo_surface_t * +static FivIoImage * open_libheif( const char *data, gsize len, const FivIoOpenContext *ioctx, GError **error) { // libheif will throw C++ exceptions on allocation failures. // The library is generally awful through and through. struct heif_context *ctx = heif_context_alloc(); - cairo_surface_t *result = NULL, *result_tail = NULL; + FivIoImage *result = NULL, *result_tail = NULL; struct heif_error err; err = heif_context_read_from_memory_without_copy(ctx, data, len, NULL); @@ -2356,14 +2602,14 @@ open_libheif( heif_image_handle_release(handle); } if (!result) { - g_clear_pointer(&result, cairo_surface_destroy); + g_clear_pointer(&result, fiv_io_image_unref); set_error(error, "empty or unsupported image"); } g_free(ids); fail_read: heif_context_free(ctx); - return fiv_io_profile_finalize(result, ioctx->screen_profile); + return fiv_io_cmm_finish(ioctx->cmm, result, ioctx->screen_profile); } #endif // HAVE_LIBHEIF -------------------------------------------------------- @@ -2470,7 +2716,7 @@ fiv_io_tiff_warning(G_GNUC_UNUSED thandle_t h, // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static cairo_surface_t * +static FivIoImage * load_libtiff_directory(TIFF *tiff, GError **error) { char emsg[1024] = ""; @@ -2486,22 +2732,26 @@ load_libtiff_directory(TIFF *tiff, GError **error) return NULL; } - cairo_surface_t *surface = NULL; + FivIoImage *I = NULL; if (image.width > G_MAXINT || image.height >= G_MAXINT || G_MAXUINT32 / image.width < image.height) { set_error(error, "image dimensions too large"); goto fail; } - surface = cairo_image_surface_create(image.alpha != EXTRASAMPLE_UNSPECIFIED + I = fiv_io_image_new(image.alpha != EXTRASAMPLE_UNSPECIFIED ? CAIRO_FORMAT_ARGB32 : CAIRO_FORMAT_RGB24, image.width, image.height); + if (!I) { + set_error(error, "image allocation failure"); + goto fail; + } image.req_orientation = ORIENTATION_LEFTTOP; - uint32_t *raster = (uint32_t *) cairo_image_surface_get_data(surface); + uint32_t *raster = (uint32_t *) I->data; if (!TIFFRGBAImageGet(&image, raster, image.width, image.height)) { - g_clear_pointer(&surface, cairo_surface_destroy); + g_clear_pointer(&I, fiv_io_image_unref); goto fail; } @@ -2513,40 +2763,36 @@ load_libtiff_directory(TIFF *tiff, GError **error) } // It seems that neither GIMP nor Photoshop use unassociated alpha. if (image.alpha == EXTRASAMPLE_UNASSALPHA) - fiv_io_premultiply_argb32(surface); + fiv_io_premultiply_argb32(I); - cairo_surface_mark_dirty(surface); // XXX: The whole file is essentially an Exif, any ideas? + // TODO(p): TIFF has a number of fields that an ICC profile can be + // constructed from--it's not a good idea to blindly default to sRGB + // if we don't find an ICC profile. const uint32_t meta_len = 0; const void *meta = NULL; - if (TIFFGetField(tiff, TIFFTAG_ICCPROFILE, &meta_len, &meta)) { - cairo_surface_set_user_data(surface, &fiv_io_key_icc, - g_bytes_new(meta, meta_len), (cairo_destroy_func_t) g_bytes_unref); - } - if (TIFFGetField(tiff, TIFFTAG_XMLPACKET, &meta_len, &meta)) { - cairo_surface_set_user_data(surface, &fiv_io_key_xmp, - g_bytes_new(meta, meta_len), (cairo_destroy_func_t) g_bytes_unref); - } + if (TIFFGetField(tiff, TIFFTAG_ICCPROFILE, &meta_len, &meta)) + I->icc = g_bytes_new(meta, meta_len); + if (TIFFGetField(tiff, TIFFTAG_XMLPACKET, &meta_len, &meta)) + I->xmp = g_bytes_new(meta, meta_len); // Don't ask. The API is high, alright, I'm just not sure about the level. uint16_t orientation = 0; if (TIFFGetField(tiff, TIFFTAG_ORIENTATION, &orientation)) { if (orientation == 5 || orientation == 7) - cairo_surface_set_user_data( - surface, &fiv_io_key_orientation, (void *) (uintptr_t) 5, NULL); + I->orientation = 5; if (orientation == 6 || orientation == 8) - cairo_surface_set_user_data( - surface, &fiv_io_key_orientation, (void *) (uintptr_t) 7, NULL); + I->orientation = 7; } fail: TIFFRGBAImageEnd(&image); // TODO(p): It's possible to implement ClipPath easily with Cairo. - return surface; + return I; } -static cairo_surface_t * +static FivIoImage * open_libtiff( const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) { @@ -2562,42 +2808,19 @@ open_libtiff( .len = len, }; - cairo_surface_t *result = NULL, *result_tail = NULL; + FivIoImage *result = NULL, *result_tail = NULL; TIFF *tiff = TIFFClientOpen(ctx->uri, "rm" /* Avoid mmap. */, &h, fiv_io_tiff_read, fiv_io_tiff_write, fiv_io_tiff_seek, fiv_io_tiff_close, fiv_io_tiff_size, NULL, NULL); if (!tiff) goto fail; - // In Nikon NEF files, IFD0 is a tiny uncompressed thumbnail with SubIFDs-- - // two of them JPEGs, the remaining one is raw. libtiff cannot read either - // of those better versions. - // - // TODO(p): If NewSubfileType is ReducedImage, and it has SubIFDs compressed - // as old JPEG (6), decode JPEGInterchangeFormat/JPEGInterchangeFormatLength - // with libjpeg-turbo and insert them as the starting pages. - // - // This is not possible with libtiff directly, because TIFFSetSubDirectory() - // requires an ImageLength tag that's missing, and TIFFReadCustomDirectory() - // takes a privately defined struct that cannot be omitted. - // - // TODO(p): Samsung Android DNGs also claim to be TIFF/EP, but use a smaller - // uncompressed YCbCr image. Apple ProRAW uses the new JPEG Compression (7), - // with a weird Orientation. It also uses that value for its raw data. - uint32_t subtype = 0; - uint16_t subifd_count = 0; - const uint64_t *subifd_offsets = NULL; - if (TIFFGetField(tiff, TIFFTAG_SUBFILETYPE, &subtype) && - (subtype & FILETYPE_REDUCEDIMAGE) && - TIFFGetField(tiff, TIFFTAG_SUBIFD, &subifd_count, &subifd_offsets) && - subifd_count > 0 && subifd_offsets) { - } - do { // We inform about unsupported directories, but do not fail on them. GError *err = NULL; if (!try_append_page( - load_libtiff_directory(tiff, &err), &result, &result_tail)) { + load_libtiff_directory(tiff, &err), &result, &result_tail) && + err) { add_warning(ctx, "%s", err->message); g_error_free(err); } @@ -2606,7 +2829,7 @@ open_libtiff( fail: if (h.error) { - g_clear_pointer(&result, cairo_surface_destroy); + g_clear_pointer(&result, fiv_io_image_unref); set_error(error, h.error); g_free(h.error); } else if (!result) { @@ -2617,28 +2840,25 @@ fail: TIFFSetWarningHandlerExt(whe); TIFFSetErrorHandler(eh); TIFFSetWarningHandler(wh); - - // TODO(p): Colour management even for un/associated alpha channels. - // Note that TIFF has a number of fields that an ICC profile can be - // constructed from--it's not a good idea to blindly assume sRGB. - return fiv_io_profile_finalize(result, ctx->screen_profile); + return fiv_io_cmm_finish(ctx->cmm, result, ctx->screen_profile); } #endif // HAVE_LIBTIFF -------------------------------------------------------- #ifdef HAVE_GDKPIXBUF // ------------------------------------------------------ -static cairo_surface_t * +static FivIoImage * load_gdkpixbuf_argb32_unpremultiplied(GdkPixbuf *pixbuf) { int w = gdk_pixbuf_get_width(pixbuf); int h = gdk_pixbuf_get_height(pixbuf); - cairo_surface_t *surface = - cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h); + FivIoImage *image = fiv_io_image_new(CAIRO_FORMAT_ARGB32, w, h); + if (!image) + return NULL; guint length = 0; guchar *src = gdk_pixbuf_get_pixels_with_length(pixbuf, &length); int src_stride = gdk_pixbuf_get_rowstride(pixbuf); - uint32_t *dst = (uint32_t *) cairo_image_surface_get_data(surface); + uint32_t *dst = (uint32_t *) image->data; for (int y = 0; y < h; y++) { const guchar *p = src + y * src_stride; for (int x = 0; x < w; x++) { @@ -2646,11 +2866,10 @@ load_gdkpixbuf_argb32_unpremultiplied(GdkPixbuf *pixbuf) p += 4; } } - cairo_surface_mark_dirty(surface); - return surface; + return image; } -static cairo_surface_t * +static FivIoImage * open_gdkpixbuf( const char *data, gsize len, const FivIoOpenContext *ctx, GError **error) { @@ -2668,16 +2887,33 @@ open_gdkpixbuf( gdk_pixbuf_get_n_channels(pixbuf) == 4 && gdk_pixbuf_get_bits_per_sample(pixbuf) == 8; - cairo_surface_t *surface = NULL; - if (custom_argb32) - surface = load_gdkpixbuf_argb32_unpremultiplied(pixbuf); - else - surface = gdk_cairo_surface_create_from_pixbuf(pixbuf, 1, NULL); - - cairo_status_t surface_status = cairo_surface_status(surface); - if (surface_status != CAIRO_STATUS_SUCCESS) { - set_error(error, cairo_status_to_string(surface_status)); + FivIoImage *image = NULL; + if (custom_argb32) { + image = load_gdkpixbuf_argb32_unpremultiplied(pixbuf); + } else if ((image = fiv_io_image_new(CAIRO_FORMAT_ARGB32, + gdk_pixbuf_get_width(pixbuf), gdk_pixbuf_get_height(pixbuf)))) { + // TODO(p): Ideally, don't go through Cairo at all. + cairo_surface_t *surface = fiv_io_image_to_surface_noref(image); + cairo_t *cr = cairo_create(surface); cairo_surface_destroy(surface); + + // Don't depend on GDK being initialized, to speed up thumbnailing + // (calling gdk_cairo_surface_create_from_pixbuf() would). + gdk_cairo_set_source_pixbuf(cr, pixbuf, 0, 0); + cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); + cairo_paint(cr); + + // If the source was opaque, so will be the destination. + if (cairo_pattern_get_surface(cairo_get_source(cr), &surface) == + CAIRO_STATUS_SUCCESS) { + if (cairo_surface_get_content(surface) == CAIRO_CONTENT_COLOR) + image->format = CAIRO_FORMAT_RGB24; + } + cairo_destroy(cr); + } + + if (!image) { + set_error(error, "image allocation failure"); g_object_unref(pixbuf); return NULL; } @@ -2686,52 +2922,29 @@ open_gdkpixbuf( if (orientation && strlen(orientation) == 1) { int n = *orientation - '0'; if (n >= 1 && n <= 8) - cairo_surface_set_user_data( - surface, &fiv_io_key_orientation, (void *) (uintptr_t) n, NULL); + image->orientation = n; } const char *icc_profile = gdk_pixbuf_get_option(pixbuf, "icc-profile"); if (icc_profile) { gsize out_len = 0; guchar *raw = g_base64_decode(icc_profile, &out_len); - if (raw) { - cairo_surface_set_user_data(surface, &fiv_io_key_icc, - g_bytes_new_take(raw, out_len), - (cairo_destroy_func_t) g_bytes_unref); - } + if (raw) + image->icc = g_bytes_new_take(raw, out_len); } g_object_unref(pixbuf); - if (custom_argb32) { - fiv_io_profile_xrgb32_page(surface, ctx->screen_profile); - fiv_io_premultiply_argb32_page(surface); - } else { - surface = fiv_io_profile_finalize(surface, ctx->screen_profile); - } - return surface; + if (custom_argb32) + fiv_io_cmm_argb32_premultiply_page( + ctx->cmm, image, ctx->screen_profile); + else + image = fiv_io_cmm_finish(ctx->cmm, image, ctx->screen_profile); + return image; } #endif // HAVE_GDKPIXBUF ------------------------------------------------------ -// TODO(p): Check that all cairo_surface_set_user_data() calls succeed. -cairo_user_data_key_t fiv_io_key_exif; -cairo_user_data_key_t fiv_io_key_orientation; -cairo_user_data_key_t fiv_io_key_icc; -cairo_user_data_key_t fiv_io_key_xmp; -cairo_user_data_key_t fiv_io_key_thum; -cairo_user_data_key_t fiv_io_key_text; - -cairo_user_data_key_t fiv_io_key_frame_next; -cairo_user_data_key_t fiv_io_key_frame_previous; -cairo_user_data_key_t fiv_io_key_frame_duration; -cairo_user_data_key_t fiv_io_key_loops; - -cairo_user_data_key_t fiv_io_key_page_next; -cairo_user_data_key_t fiv_io_key_page_previous; - -cairo_user_data_key_t fiv_io_key_render; - -cairo_surface_t * +FivIoImage * fiv_io_open(const FivIoOpenContext *ctx, GError **error) { // TODO(p): Don't always load everything into memory, test type first, @@ -2752,56 +2965,69 @@ fiv_io_open(const FivIoOpenContext *ctx, GError **error) gchar *data = NULL; gsize len = 0; - if (!g_file_load_contents(file, NULL, &data, &len, NULL, error)) + gboolean success = + g_file_load_contents(file, NULL, &data, &len, NULL, error); + g_object_unref(file); + if (!success) return NULL; - cairo_surface_t *surface = fiv_io_open_from_data(data, len, ctx, error); + FivIoImage *image = fiv_io_open_from_data(data, len, ctx, error); g_free(data); - return surface; + return image; } -cairo_surface_t * +FivIoImage * fiv_io_open_from_data( const char *data, size_t len, const FivIoOpenContext *ctx, GError **error) { wuffs_base__slice_u8 prefix = wuffs_base__make_slice_u8((uint8_t *) data, len); - cairo_surface_t *surface = NULL; + FivIoImage *image = NULL; switch (wuffs_base__magic_number_guess_fourcc(prefix, true /* closed */)) { case WUFFS_BASE__FOURCC__BMP: // Note that BMP can redirect into another format, // which is so far unsupported here. - surface = open_wuffs_using( + image = open_wuffs_using( wuffs_bmp__decoder__alloc_as__wuffs_base__image_decoder, data, len, ctx, error); break; case WUFFS_BASE__FOURCC__GIF: - surface = open_wuffs_using( + image = open_wuffs_using( wuffs_gif__decoder__alloc_as__wuffs_base__image_decoder, data, len, ctx, error); break; case WUFFS_BASE__FOURCC__PNG: - surface = open_wuffs_using( + image = open_wuffs_using( wuffs_png__decoder__alloc_as__wuffs_base__image_decoder, data, len, ctx, error); break; case WUFFS_BASE__FOURCC__TGA: - surface = open_wuffs_using( + image = open_wuffs_using( wuffs_tga__decoder__alloc_as__wuffs_base__image_decoder, data, len, ctx, error); break; case WUFFS_BASE__FOURCC__JPEG: - surface = ctx->enhance - ? open_libjpeg_enhanced(data, len, ctx, error) - : open_libjpeg_turbo(data, len, ctx, error); + image = open_libjpeg_turbo(data, len, ctx, error); break; case WUFFS_BASE__FOURCC__WEBP: - surface = open_libwebp(data, len, ctx, error); + image = open_libwebp(data, len, ctx, error); break; default: + // Try to extract full-size previews from TIFF/EP-compatible raws, + // but allow for running the full render. +#ifdef HAVE_LIBRAW // --------------------------------------------------------- + if (!ctx->enhance) { +#endif // HAVE_LIBRAW --------------------------------------------------------- + if ((image = open_tiff_ep(data, len, ctx, error))) + break; + if (error) { + g_debug("%s", (*error)->message); + g_clear_error(error); + } #ifdef HAVE_LIBRAW // --------------------------------------------------------- - if ((surface = open_libraw(data, len, error))) + } + if ((image = open_libraw(data, len, ctx, error))) break; // TODO(p): We should try to pass actual processing errors through, @@ -2812,7 +3038,7 @@ fiv_io_open_from_data( } #endif // HAVE_LIBRAW --------------------------------------------------------- #ifdef HAVE_RESVG // ---------------------------------------------------------- - if ((surface = open_resvg(data, len, ctx, error))) + if ((image = open_resvg(data, len, ctx, error))) break; if (error) { g_debug("%s", (*error)->message); @@ -2820,7 +3046,7 @@ fiv_io_open_from_data( } #endif // HAVE_RESVG ---------------------------------------------------------- #ifdef HAVE_LIBRSVG // -------------------------------------------------------- - if ((surface = open_librsvg(data, len, ctx, error))) + if ((image = open_librsvg(data, len, ctx, error))) break; // XXX: It doesn't look like librsvg can return sensible errors. @@ -2830,7 +3056,7 @@ fiv_io_open_from_data( } #endif // HAVE_LIBRSVG -------------------------------------------------------- #ifdef HAVE_XCURSOR //--------------------------------------------------------- - if ((surface = open_xcursor(data, len, error))) + if ((image = open_xcursor(data, len, ctx, error))) break; if (error) { g_debug("%s", (*error)->message); @@ -2838,7 +3064,7 @@ fiv_io_open_from_data( } #endif // HAVE_XCURSOR -------------------------------------------------------- #ifdef HAVE_LIBHEIF //--------------------------------------------------------- - if ((surface = open_libheif(data, len, ctx, error))) + if ((image = open_libheif(data, len, ctx, error))) break; if (error) { g_debug("%s", (*error)->message); @@ -2847,7 +3073,7 @@ fiv_io_open_from_data( #endif // HAVE_LIBHEIF -------------------------------------------------------- #ifdef HAVE_LIBTIFF //--------------------------------------------------------- // This needs to be positioned after LibRaw. - if ((surface = open_libtiff(data, len, ctx, error))) + if ((image = open_libtiff(data, len, ctx, error))) break; if (error) { g_debug("%s", (*error)->message); @@ -2860,10 +3086,12 @@ fiv_io_open_from_data( #ifdef HAVE_GDKPIXBUF // ------------------------------------------------------ // This is used as a last resort, the rest above is special-cased. - if (!surface) { + if (!image) { GError *err = NULL; - if ((surface = open_gdkpixbuf(data, len, ctx, &err))) { + if ((image = open_gdkpixbuf(data, len, ctx, &err))) { g_clear_error(error); + } else if (!err) { + // Contrary to documentation, this is a possible outcome (libheif). } else if (err->code == GDK_PIXBUF_ERROR_UNKNOWN_TYPE) { g_error_free(err); } else { @@ -2876,17 +3104,13 @@ fiv_io_open_from_data( // gdk-pixbuf only gives out this single field--cater to its limitations, // since we'd really like to have it. // TODO(p): The Exif orientation should be ignored in JPEG-XL at minimum. - GBytes *exif = NULL; gsize exif_len = 0; gconstpointer exif_data = NULL; - if (surface && - (exif = cairo_surface_get_user_data(surface, &fiv_io_key_exif)) && - (exif_data = g_bytes_get_data(exif, &exif_len))) { - cairo_surface_set_user_data(surface, &fiv_io_key_orientation, - (void *) (uintptr_t) fiv_io_exif_orientation(exif_data, exif_len), - NULL); + if (image && image->exif && + (exif_data = g_bytes_get_data(image->exif, &exif_len))) { + image->orientation = fiv_io_exif_orientation(exif_data, exif_len); } - return surface; + return image; } // --- Thumbnail passing utilities --------------------------------------------- @@ -2956,380 +3180,112 @@ fiv_io_deserialize(GBytes *bytes, guint64 *user_data) return surface; } -// --- Filesystem -------------------------------------------------------------- - -#include "xdg.h" - -static void -model_entry_finalize(FivIoModelEntry *entry) -{ - g_free(entry->uri); - g_free(entry->target_uri); - g_free(entry->collate_key); -} - -struct _FivIoModel { - GObject parent_instance; - GPatternSpec **supported_patterns; - - GFile *directory; ///< Currently loaded directory - GFileMonitor *monitor; ///< "directory" monitoring - GArray *subdirs; ///< "directory" contents - GArray *files; ///< "directory" contents - - FivIoModelSort sort_field; ///< How to sort - gboolean sort_descending; ///< Whether to sort in reverse - gboolean filtering; ///< Only show non-hidden, supported -}; - -G_DEFINE_TYPE(FivIoModel, fiv_io_model, G_TYPE_OBJECT) - -enum { - PROP_FILTERING = 1, - PROP_SORT_FIELD, - PROP_SORT_DESCENDING, - N_PROPERTIES -}; - -static GParamSpec *model_properties[N_PROPERTIES]; - -enum { - FILES_CHANGED, - SUBDIRECTORIES_CHANGED, - LAST_SIGNAL, -}; - -// Globals are, sadly, the canonical way of storing signal numbers. -static guint model_signals[LAST_SIGNAL]; - // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static gboolean -model_supports(FivIoModel *self, const char *filename) -{ - gchar *utf8 = g_filename_to_utf8(filename, -1, NULL, NULL, NULL); - if (!utf8) - return FALSE; - - gchar *lc = g_utf8_strdown(utf8, -1); - gsize lc_length = strlen(lc); - gchar *reversed = g_utf8_strreverse(lc, lc_length); - g_free(utf8); - - // fnmatch() uses the /locale encoding/, and isn't present on Windows. - // TODO(p): Consider using g_file_info_get_display_name() for direct UTF-8. - gboolean result = FALSE; - for (GPatternSpec **p = self->supported_patterns; *p; p++) - if ((result = g_pattern_spec_match(*p, lc_length, lc, reversed))) - break; - - g_free(lc); - g_free(reversed); - return result; -} - -static inline int -model_compare_entries(FivIoModel *self, - const FivIoModelEntry *entry1, GFile *file1, - const FivIoModelEntry *entry2, GFile *file2) -{ - if (g_file_has_prefix(file1, file2)) - return +1; - if (g_file_has_prefix(file2, file1)) - return -1; - - int result = 0; - switch (self->sort_field) { - case FIV_IO_MODEL_SORT_MTIME: - result -= entry1->mtime_msec < entry2->mtime_msec; - result += entry1->mtime_msec > entry2->mtime_msec; - if (result != 0) - break; - - // Fall-through - case FIV_IO_MODEL_SORT_NAME: - case FIV_IO_MODEL_SORT_COUNT: - result = strcmp(entry1->collate_key, entry2->collate_key); - } - return self->sort_descending ? -result : +result; -} - -static gint -model_compare(gconstpointer a, gconstpointer b, gpointer user_data) +static cairo_status_t +write_to_byte_array( + void *closure, const unsigned char *data, unsigned int length) { - const FivIoModelEntry *entry1 = a; - const FivIoModelEntry *entry2 = b; - GFile *file1 = g_file_new_for_uri(entry1->uri); - GFile *file2 = g_file_new_for_uri(entry2->uri); - int result = model_compare_entries(user_data, entry1, file1, entry2, file2); - g_object_unref(file1); - g_object_unref(file2); - return result; + g_byte_array_append(closure, data, length); + return CAIRO_STATUS_SUCCESS; } -static void -model_resort(FivIoModel *self) +GBytes * +fiv_io_serialize_for_search(cairo_surface_t *surface, GError **error) { - g_array_sort_with_data(self->subdirs, model_compare, self); - g_array_sort_with_data(self->files, model_compare, self); - - g_signal_emit(self, model_signals[FILES_CHANGED], 0); - g_signal_emit(self, model_signals[SUBDIRECTORIES_CHANGED], 0); -} - -static gboolean -model_reload(FivIoModel *self, GError **error) -{ - g_array_set_size(self->subdirs, 0); - g_array_set_size(self->files, 0); - - GFileEnumerator *enumerator = g_file_enumerate_children(self->directory, - G_FILE_ATTRIBUTE_STANDARD_TYPE "," - G_FILE_ATTRIBUTE_STANDARD_NAME "," - G_FILE_ATTRIBUTE_STANDARD_TARGET_URI "," - G_FILE_ATTRIBUTE_STANDARD_IS_HIDDEN "," - G_FILE_ATTRIBUTE_TIME_MODIFIED "," - G_FILE_ATTRIBUTE_TIME_MODIFIED_USEC, - G_FILE_QUERY_INFO_NONE, NULL, error); - if (!enumerator) { - // Note that this has had a side-effect of clearing all entries. - g_signal_emit(self, model_signals[FILES_CHANGED], 0); - g_signal_emit(self, model_signals[SUBDIRECTORIES_CHANGED], 0); - return FALSE; - } - - GFileInfo *info = NULL; - GFile *child = NULL; - GError *e = NULL; - while (TRUE) { - if (!g_file_enumerator_iterate(enumerator, &info, &child, NULL, &e) && - e) { - g_warning("%s", e->message); - g_clear_error(&e); - continue; - } - - if (!info) - break; - if (self->filtering && g_file_info_get_is_hidden(info)) - continue; + g_return_val_if_fail( + surface && cairo_surface_get_type(surface) == CAIRO_SURFACE_TYPE_IMAGE, + NULL); - FivIoModelEntry entry = {.uri = g_file_get_uri(child), - .target_uri = g_strdup(g_file_info_get_attribute_string( - info, G_FILE_ATTRIBUTE_STANDARD_TARGET_URI))}; - GDateTime *mtime = g_file_info_get_modification_date_time(info); - if (mtime) { - entry.mtime_msec = g_date_time_to_unix(mtime) * 1000 + - g_date_time_get_microsecond(mtime) / 1000; - g_date_time_unref(mtime); + 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 - gchar *parse_name = g_file_get_parse_name(child); - // TODO(p): Make it possible to use g_utf8_collate_key() instead, - // which does not use natural sorting. - entry.collate_key = g_utf8_collate_key_for_filename(parse_name, -1); - g_free(parse_name); - - const char *name = g_file_info_get_name(info); - if (g_file_info_get_file_type(info) == G_FILE_TYPE_DIRECTORY) - g_array_append_val(self->subdirs, entry); - else if (!self->filtering || model_supports(self, name)) - g_array_append_val(self->files, entry); - else - model_entry_finalize(&entry); + // 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; } - g_object_unref(enumerator); - - // We also emit change signals there, indirectly. - model_resort(self); - return TRUE; -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static void -fiv_io_model_finalize(GObject *gobject) -{ - FivIoModel *self = FIV_IO_MODEL(gobject); - for (GPatternSpec **p = self->supported_patterns; *p; p++) - g_pattern_spec_free(*p); - g_free(self->supported_patterns); - - g_clear_object(&self->directory); - g_clear_object(&self->monitor); - g_array_free(self->subdirs, TRUE); - g_array_free(self->files, TRUE); - - G_OBJECT_CLASS(fiv_io_model_parent_class)->finalize(gobject); -} -static void -fiv_io_model_get_property( - GObject *object, guint property_id, GValue *value, GParamSpec *pspec) -{ - FivIoModel *self = FIV_IO_MODEL(object); - switch (property_id) { - case PROP_FILTERING: - g_value_set_boolean(value, self->filtering); - break; - case PROP_SORT_FIELD: - g_value_set_int(value, self->sort_field); - break; - case PROP_SORT_DESCENDING: - g_value_set_boolean(value, self->sort_descending); - break; - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec); + tjhandle enc = tjInitCompress(); + if (!enc) { + set_error(error, tjGetErrorStr2(enc)); + return NULL; } -} -static void -fiv_io_model_set_property( - GObject *object, guint property_id, const GValue *value, GParamSpec *pspec) -{ - FivIoModel *self = FIV_IO_MODEL(object); - switch (property_id) { - case PROP_FILTERING: - if (self->filtering != g_value_get_boolean(value)) { - self->filtering = !self->filtering; - g_object_notify_by_pspec(object, model_properties[property_id]); - (void) model_reload(self, NULL /* error */); - } - break; - case PROP_SORT_FIELD: - if ((int) self->sort_field != g_value_get_int(value)) { - self->sort_field = g_value_get_int(value); - g_object_notify_by_pspec(object, model_properties[property_id]); - model_resort(self); - } - break; - case PROP_SORT_DESCENDING: - if (self->sort_descending != g_value_get_boolean(value)) { - self->sort_descending = !self->sort_descending; - g_object_notify_by_pspec(object, model_properties[property_id]); - model_resort(self); - } - break; - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID(object, property_id, pspec); + 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; } -} -static void -fiv_io_model_class_init(FivIoModelClass *klass) -{ - GObjectClass *object_class = G_OBJECT_CLASS(klass); - object_class->get_property = fiv_io_model_get_property; - object_class->set_property = fiv_io_model_set_property; - object_class->finalize = fiv_io_model_finalize; - - model_properties[PROP_FILTERING] = g_param_spec_boolean( - "filtering", "Filtering", "Only show non-hidden, supported entries", - TRUE, G_PARAM_READWRITE); - // TODO(p): GObject enumerations are annoying, but this should be one. - model_properties[PROP_SORT_FIELD] = g_param_spec_int( - "sort-field", "Sort field", "Sort order", - FIV_IO_MODEL_SORT_MIN, FIV_IO_MODEL_SORT_MAX, - FIV_IO_MODEL_SORT_NAME, G_PARAM_READWRITE); - model_properties[PROP_SORT_DESCENDING] = g_param_spec_boolean( - "sort-descending", "Sort descending", "Use reverse sort order", - FALSE, G_PARAM_READWRITE); - g_object_class_install_properties( - object_class, N_PROPERTIES, model_properties); - - // TODO(p): Arguments something like: index, added, removed. - model_signals[FILES_CHANGED] = - g_signal_new("files-changed", G_TYPE_FROM_CLASS(klass), 0, 0, - NULL, NULL, NULL, G_TYPE_NONE, 0); - model_signals[SUBDIRECTORIES_CHANGED] = - g_signal_new("subdirectories-changed", G_TYPE_FROM_CLASS(klass), 0, 0, - NULL, NULL, NULL, G_TYPE_NONE, 0); -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static void -fiv_io_model_init(FivIoModel *self) -{ - self->filtering = TRUE; - - char **types = fiv_io_all_supported_media_types(); - char **globs = extract_mime_globs((const char **) types); - g_strfreev(types); - - gsize n = g_strv_length(globs); - self->supported_patterns = - g_malloc0_n(n + 1, sizeof *self->supported_patterns); - while (n--) - self->supported_patterns[n] = g_pattern_spec_new(globs[n]); - g_strfreev(globs); - - self->files = g_array_new(FALSE, TRUE, sizeof(FivIoModelEntry)); - self->subdirs = g_array_new(FALSE, TRUE, sizeof(FivIoModelEntry)); - g_array_set_clear_func( - self->subdirs, (GDestroyNotify) model_entry_finalize); - g_array_set_clear_func( - self->files, (GDestroyNotify) model_entry_finalize); -} - -gboolean -fiv_io_model_open(FivIoModel *self, GFile *directory, GError **error) -{ - g_return_val_if_fail(FIV_IS_IO_MODEL(self), FALSE); - g_return_val_if_fail(G_IS_FILE(directory), FALSE); - - g_clear_object(&self->directory); - g_clear_object(&self->monitor); - self->directory = g_object_ref(directory); - - // TODO(p): Process the ::changed signal. - self->monitor = g_file_monitor_directory( - directory, G_FILE_MONITOR_WATCH_MOVES, NULL, NULL /* error */); - return model_reload(self, error); -} - -GFile * -fiv_io_model_get_location(FivIoModel *self) -{ - g_return_val_if_fail(FIV_IS_IO_MODEL(self), NULL); - return self->directory; -} - -const FivIoModelEntry * -fiv_io_model_get_files(FivIoModel *self, gsize *len) -{ - *len = self->files->len; - return (const FivIoModelEntry *) self->files->data; -} - -const FivIoModelEntry * -fiv_io_model_get_subdirs(FivIoModel *self, gsize *len) -{ - *len = self->subdirs->len; - return (const FivIoModelEntry *) self->subdirs->data; + tjDestroy(enc); + return g_bytes_new_with_free_func( + jpeg, length, (GDestroyNotify) tjFree, jpeg); } // --- Export ------------------------------------------------------------------ unsigned char * fiv_io_encode_webp( - cairo_surface_t *surface, const WebPConfig *config, size_t *len) + FivIoImage *image, const WebPConfig *config, size_t *len) { - cairo_format_t format = cairo_image_surface_get_format(surface); - int w = cairo_image_surface_get_width(surface); - int h = cairo_image_surface_get_height(surface); - if (format != CAIRO_FORMAT_ARGB32 && - format != CAIRO_FORMAT_RGB24) { - cairo_surface_t *converted = - cairo_image_surface_create((format = CAIRO_FORMAT_ARGB32), w, h); - cairo_t *cr = cairo_create(converted); + if (image->format != CAIRO_FORMAT_ARGB32 && + image->format != CAIRO_FORMAT_RGB24) { + FivIoImage *converted = + fiv_io_image_new(CAIRO_FORMAT_ARGB32, image->width, image->height); + + cairo_surface_t *surface = fiv_io_image_to_surface_noref(converted); + cairo_t *cr = cairo_create(surface); + cairo_surface_destroy(surface); + + surface = fiv_io_image_to_surface_noref(image); cairo_set_source_surface(cr, surface, 0, 0); + cairo_surface_destroy(surface); cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); cairo_paint(cr); cairo_destroy(cr); - surface = converted; + image = converted; } else { - surface = cairo_surface_reference(surface); + image = fiv_io_image_ref(image); } WebPMemoryWriter writer = {}; @@ -3339,27 +3295,26 @@ fiv_io_encode_webp( goto fail; picture.use_argb = true; - picture.width = w; - picture.height = h; + picture.width = image->width; + picture.height = image->height; if (!WebPPictureAlloc(&picture)) goto fail; // Cairo uses a similar internal format, so we should be able to // copy it over and fix up the minor differences. // This is written to be easy to follow rather than fast. - int stride = cairo_image_surface_get_stride(surface); - if (picture.argb_stride != w || - picture.argb_stride * (int) sizeof *picture.argb != stride || - INT_MAX / picture.argb_stride < h) + if (picture.argb_stride != (int) image->width || + picture.argb_stride * sizeof *picture.argb != image->stride || + UINT32_MAX / picture.argb_stride < image->height) goto fail_compatibility; uint32_t *argb = - memcpy(picture.argb, cairo_image_surface_get_data(surface), stride * h); - if (format == CAIRO_FORMAT_ARGB32) - for (int i = h * picture.argb_stride; i-- > 0; argb++) + memcpy(picture.argb, image->data, image->stride * image->height); + if (image->format == CAIRO_FORMAT_ARGB32) + for (int i = image->height * picture.argb_stride; i-- > 0; argb++) *argb = wuffs_base__color_u32_argb_premul__as__color_u32_argb_nonpremul(*argb); else - for (int i = h * picture.argb_stride; i-- > 0; argb++) + for (int i = image->height * picture.argb_stride; i-- > 0; argb++) *argb |= 0xFF000000; // TODO(p): Prevent or propagate VP8_ENC_ERROR_BAD_DIMENSION. @@ -3371,13 +3326,13 @@ fiv_io_encode_webp( fail_compatibility: WebPPictureFree(&picture); fail: - cairo_surface_destroy(surface); + fiv_io_image_unref(image); *len = writer.size; return writer.mem; } static WebPData -encode_lossless_webp(cairo_surface_t *surface) +encode_lossless_webp(FivIoImage *image) { WebPData bitstream = {}; WebPConfig config = {}; @@ -3388,12 +3343,12 @@ encode_lossless_webp(cairo_surface_t *surface) if (!WebPValidateConfig(&config)) return bitstream; - bitstream.bytes = fiv_io_encode_webp(surface, &config, &bitstream.size); + bitstream.bytes = fiv_io_encode_webp(image, &config, &bitstream.size); return bitstream; } static gboolean -encode_webp_image(WebPMux *mux, cairo_surface_t *frame) +encode_webp_image(WebPMux *mux, FivIoImage *frame) { WebPData bitstream = encode_lossless_webp(frame); gboolean ok = WebPMuxSetImage(mux, &bitstream, true) == WEBP_MUX_OK; @@ -3402,15 +3357,13 @@ encode_webp_image(WebPMux *mux, cairo_surface_t *frame) } static gboolean -encode_webp_animation(WebPMux *mux, cairo_surface_t *page) +encode_webp_animation(WebPMux *mux, FivIoImage *page) { gboolean ok = TRUE; - for (cairo_surface_t *frame = page; ok && frame; frame = - cairo_surface_get_user_data(frame, &fiv_io_key_frame_next)) { + for (FivIoImage *frame = page; ok && frame; frame = frame->frame_next) { WebPMuxFrameInfo info = { .bitstream = encode_lossless_webp(frame), - .duration = (intptr_t) cairo_surface_get_user_data( - frame, &fiv_io_key_frame_duration), + .duration = frame->frame_duration, .id = WEBP_CHUNK_ANMF, .dispose_method = WEBP_MUX_DISPOSE_NONE, .blend_method = WEBP_MUX_NO_BLEND, @@ -3420,8 +3373,7 @@ encode_webp_animation(WebPMux *mux, cairo_surface_t *page) } WebPMuxAnimParams params = { .bgcolor = 0x00000000, // BGRA, curiously. - .loop_count = (uintptr_t) - cairo_surface_get_user_data(page, &fiv_io_key_loops), + .loop_count = page->loops, }; return ok && WebPMuxSetAnimationParams(mux, ¶ms) == WEBP_MUX_OK; } @@ -3439,7 +3391,7 @@ set_metadata(WebPMux *mux, const char *fourcc, GBytes *data) } gboolean -fiv_io_save(cairo_surface_t *page, cairo_surface_t *frame, FivIoProfile target, +fiv_io_save(FivIoImage *page, FivIoImage *frame, FivIoProfile *target, const char *path, GError **error) { g_return_val_if_fail(page != NULL, FALSE); @@ -3449,17 +3401,14 @@ fiv_io_save(cairo_surface_t *page, cairo_surface_t *frame, FivIoProfile target, WebPMux *mux = WebPMuxNew(); if (frame) ok = encode_webp_image(mux, frame); - else if (!cairo_surface_get_user_data(page, &fiv_io_key_frame_next)) + else if (!page->frame_next) ok = encode_webp_image(mux, page); else ok = encode_webp_animation(mux, page); - ok = ok && set_metadata(mux, "EXIF", - cairo_surface_get_user_data(page, &fiv_io_key_exif)); - ok = ok && set_metadata(mux, "ICCP", - cairo_surface_get_user_data(page, &fiv_io_key_icc)); - ok = ok && set_metadata(mux, "XMP ", - cairo_surface_get_user_data(page, &fiv_io_key_xmp)); + ok = ok && set_metadata(mux, "EXIF", page->exif); + ok = ok && set_metadata(mux, "ICCP", page->icc); + ok = ok && set_metadata(mux, "XMP ", page->xmp); GBytes *iccp = NULL; if (ok && target && (iccp = fiv_io_profile_to_bytes(target))) @@ -3484,71 +3433,62 @@ fiv_io_save(cairo_surface_t *page, cairo_surface_t *frame, FivIoProfile target, // --- Metadata ---------------------------------------------------------------- void -fiv_io_orientation_dimensions(cairo_surface_t *surface, - FivIoOrientation orientation, double *w, double *h) -{ - cairo_rectangle_t extents = {}; - switch (cairo_surface_get_type(surface)) { - case CAIRO_SURFACE_TYPE_IMAGE: - extents.width = cairo_image_surface_get_width(surface); - extents.height = cairo_image_surface_get_height(surface); - break; - case CAIRO_SURFACE_TYPE_RECORDING: - if (!cairo_recording_surface_get_extents(surface, &extents)) - cairo_recording_surface_ink_extents(surface, - &extents.x, &extents.y, &extents.width, &extents.height); - break; - default: - g_assert_not_reached(); - } - +fiv_io_orientation_dimensions( + const FivIoImage *image, FivIoOrientation orientation, double *w, double *h) +{ switch (orientation) { case FivIoOrientation90: case FivIoOrientationMirror90: case FivIoOrientation270: case FivIoOrientationMirror270: - *w = extents.height; - *h = extents.width; + *w = image->height; + *h = image->width; break; default: - *w = extents.width; - *h = extents.height; + *w = image->width; + *h = image->height; } } cairo_matrix_t -fiv_io_orientation_apply(cairo_surface_t *surface, +fiv_io_orientation_apply(const FivIoImage *image, FivIoOrientation orientation, double *width, double *height) { - fiv_io_orientation_dimensions(surface, orientation, width, height); + fiv_io_orientation_dimensions(image, orientation, width, height); + return fiv_io_orientation_matrix(orientation, *width, *height); +} +cairo_matrix_t +fiv_io_orientation_matrix( + FivIoOrientation orientation, double width, double height) +{ cairo_matrix_t matrix = {}; cairo_matrix_init_identity(&matrix); switch (orientation) { case FivIoOrientation90: cairo_matrix_rotate(&matrix, -M_PI_2); - cairo_matrix_translate(&matrix, -*width, 0); + cairo_matrix_translate(&matrix, -width, 0); break; case FivIoOrientation180: cairo_matrix_scale(&matrix, -1, -1); - cairo_matrix_translate(&matrix, -*width, -*height); + cairo_matrix_translate(&matrix, -width, -height); break; case FivIoOrientation270: cairo_matrix_rotate(&matrix, +M_PI_2); - cairo_matrix_translate(&matrix, 0, -*height); + cairo_matrix_translate(&matrix, 0, -height); break; case FivIoOrientationMirror0: cairo_matrix_scale(&matrix, -1, +1); - cairo_matrix_translate(&matrix, -*width, 0); + cairo_matrix_translate(&matrix, -width, 0); break; case FivIoOrientationMirror90: cairo_matrix_rotate(&matrix, +M_PI_2); cairo_matrix_scale(&matrix, -1, +1); - cairo_matrix_translate(&matrix, -*width, -*height); + cairo_matrix_translate(&matrix, -width, -height); break; case FivIoOrientationMirror180: cairo_matrix_scale(&matrix, +1, -1); - cairo_matrix_translate(&matrix, 0, -*height); + cairo_matrix_translate(&matrix, 0, -height); break; case FivIoOrientationMirror270: cairo_matrix_rotate(&matrix, -M_PI_2); @@ -3562,49 +3502,28 @@ fiv_io_orientation_apply(cairo_surface_t *surface, FivIoOrientation fiv_io_exif_orientation(const guint8 *tiff, gsize len) { - // libtiff also knows how to do this, but it's not a lot of code. // The "Orientation" tag/field is part of Baseline TIFF 6.0 (1992), // it just so happens that Exif is derived from this format. // There is no other meaningful placement for this than right in IFD0, // describing the main image. - const uint8_t *end = tiff + len, - le[4] = {'I', 'I', 42, 0}, - be[4] = {'M', 'M', 0, 42}; - - uint16_t (*u16)(const uint8_t *) = NULL; - uint32_t (*u32)(const uint8_t *) = NULL; - if (tiff + 8 > end) { - return FivIoOrientationUnknown; - } else if (!memcmp(tiff, le, sizeof le)) { - u16 = wuffs_base__peek_u16le__no_bounds_check; - u32 = wuffs_base__peek_u32le__no_bounds_check; - } else if (!memcmp(tiff, be, sizeof be)) { - u16 = wuffs_base__peek_u16be__no_bounds_check; - u32 = wuffs_base__peek_u32be__no_bounds_check; - } else { - return FivIoOrientationUnknown; - } - - const uint8_t *ifd0 = tiff + u32(tiff + 4); - if (ifd0 + 2 > end) + struct tiffer T = {}; + if (!tiffer_init(&T, tiff, len) || !tiffer_next_ifd(&T)) return FivIoOrientationUnknown; - uint16_t fields = u16(ifd0); - enum { BYTE = 1, ASCII, SHORT, LONG, RATIONAL, - SBYTE, UNDEFINED, SSHORT, SLONG, SRATIONAL, FLOAT, DOUBLE }; - enum { Orientation = 274 }; - for (const guint8 *p = ifd0 + 2; fields-- && p + 12 <= end; p += 12) { - uint16_t tag = u16(p), type = u16(p + 2), value16 = u16(p + 8); - uint32_t count = u32(p + 4); - if (G_UNLIKELY(tag == Orientation && type == SHORT && count == 1 && - value16 >= 1 && value16 <= 8)) - return value16; + struct tiffer_entry entry = {}; + while (tiffer_next_entry(&T, &entry)) { + int64_t orientation = 0; + if (G_UNLIKELY(entry.tag == TIFF_Orientation) && + entry.type == TIFFER_SHORT && entry.remaining_count == 1 && + tiffer_integer(&T, &entry, &orientation) && + orientation >= 1 && orientation <= 8) + return orientation; } return FivIoOrientationUnknown; } gboolean -fiv_io_save_metadata(cairo_surface_t *page, const char *path, GError **error) +fiv_io_save_metadata(const FivIoImage *page, const char *path, GError **error) { g_return_val_if_fail(page != NULL, FALSE); @@ -3619,14 +3538,12 @@ fiv_io_save_metadata(cairo_surface_t *page, const char *path, GError **error) // (standalone) with trailing nonsense. fprintf(fp, "\xFF\001Exiv2"); - GBytes *data = NULL; gsize len = 0; gconstpointer p = NULL; // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 // I don't care if Exiv2 supports it this way. - if ((data = cairo_surface_get_user_data(page, &fiv_io_key_exif)) && - (p = g_bytes_get_data(data, &len))) { + if (page->exif && (p = g_bytes_get_data(page->exif, &len))) { while (len) { gsize chunk = MIN(len, 0xFFFF - 2 - 6); uint8_t header[10] = "\xFF\xE1\000\000Exif\000\000"; @@ -3642,8 +3559,7 @@ fiv_io_save_metadata(cairo_surface_t *page, const char *path, GError **error) } // https://www.color.org/specification/ICC1v43_2010-12.pdf B.4 - if ((data = cairo_surface_get_user_data(page, &fiv_io_key_icc)) && - (p = g_bytes_get_data(data, &len))) { + if (page->icc && (p = g_bytes_get_data(page->icc, &len))) { gsize limit = 0xFFFF - 2 - 12; uint8_t current = 0, total = (len + limit - 1) / limit; while (len) { @@ -3665,8 +3581,7 @@ fiv_io_save_metadata(cairo_surface_t *page, const char *path, GError **error) // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 // If the main segment overflows, then it's a sign of bad luck, // because 1.1.3.1 is way too complex. - if ((data = cairo_surface_get_user_data(page, &fiv_io_key_xmp)) && - (p = g_bytes_get_data(data, &len))) { + if (page->xmp && (p = g_bytes_get_data(page->xmp, &len))) { while (len) { gsize chunk = MIN(len, 0xFFFF - 2 - 29); uint8_t header[33] = @@ -1,7 +1,7 @@ // // fiv-io.h: image operations // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2024, 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. @@ -22,18 +22,53 @@ #include <glib.h> #include <webp/encode.h> // WebPConfig +typedef enum _FivIoOrientation FivIoOrientation; +typedef struct _FivIoRenderClosure FivIoRenderClosure; +typedef struct _FivIoImage FivIoImage; +typedef struct _FivIoProfile FivIoProfile; + // --- Colour management ------------------------------------------------------- +// Note that without a CMM, all FivIoCmm and FivIoProfile will be returned NULL. + +GBytes *fiv_io_profile_to_bytes(FivIoProfile *profile); +void fiv_io_profile_free(FivIoProfile *self); + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +#define FIV_TYPE_IO_CMM (fiv_io_cmm_get_type()) +G_DECLARE_FINAL_TYPE(FivIoCmm, fiv_io_cmm, FIV, IO_CMM, GObject) + +FivIoCmm *fiv_io_cmm_get_default(void); -// TODO(p): Make it possible to use Skia's skcms, -// which also supports premultiplied alpha. -// NOTE: Little CMS 2.13 already supports premultiplied alpha, too. -typedef void *FivIoProfile; -FivIoProfile fiv_io_profile_new(const void *data, size_t len); -FivIoProfile fiv_io_profile_new_sRGB(void); -void fiv_io_profile_free(FivIoProfile self); +FivIoProfile *fiv_io_cmm_get_profile( + FivIoCmm *self, const void *data, size_t len); +FivIoProfile *fiv_io_cmm_get_profile_from_bytes(FivIoCmm *self, GBytes *bytes); +FivIoProfile *fiv_io_cmm_get_profile_sRGB(FivIoCmm *self); +FivIoProfile *fiv_io_cmm_get_profile_sRGB_gamma(FivIoCmm *self, double gamma); +FivIoProfile *fiv_io_cmm_get_profile_parametric( + FivIoCmm *self, double gamma, double whitepoint[2], double primaries[6]); -// From libwebp, verified to exactly match [x * a / 255]. -#define PREMULTIPLY8(a, x) (((uint32_t) (x) * (uint32_t) (a) * 32897U) >> 23) +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +void fiv_io_premultiply_argb32(FivIoImage *image); + +void fiv_io_cmm_cmyk(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target); +void fiv_io_cmm_4x16le_direct(FivIoCmm *self, unsigned char *data, + int w, int h, FivIoProfile *source, FivIoProfile *target); + +void fiv_io_cmm_argb32_premultiply(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target); +#define fiv_io_cmm_argb32_premultiply_page(cmm, page, target) \ + fiv_io_cmm_page((cmm), (page), (target), fiv_io_cmm_argb32_premultiply) + +void fiv_io_cmm_page(FivIoCmm *self, FivIoImage *page, FivIoProfile *target, + void (*frame_cb) (FivIoCmm *, + FivIoImage *, FivIoProfile *, FivIoProfile *)); +void fiv_io_cmm_any(FivIoCmm *self, + FivIoImage *image, FivIoProfile *source, FivIoProfile *target); +FivIoImage *fiv_io_cmm_finish(FivIoCmm *self, + FivIoImage *image, FivIoProfile *target); // --- Loading ----------------------------------------------------------------- @@ -41,135 +76,118 @@ extern const char *fiv_io_supported_media_types[]; gchar **fiv_io_all_supported_media_types(void); -// Userdata are typically attached to all Cairo surfaces in an animation. - -/// GBytes with plain Exif/TIFF data. -extern cairo_user_data_key_t fiv_io_key_exif; -/// FivIoOrientation, as a uintptr_t. -extern cairo_user_data_key_t fiv_io_key_orientation; -/// GBytes with plain ICC profile data. -extern cairo_user_data_key_t fiv_io_key_icc; -/// GBytes with plain XMP data. -extern cairo_user_data_key_t fiv_io_key_xmp; -/// GBytes with a WebP's THUM chunk, used for our thumbnails. -extern cairo_user_data_key_t fiv_io_key_thum; -/// GHashTable with key-value pairs from PNG's tEXt, zTXt, iTXt chunks. -/// Currently only read by fiv_io_open_png_thumbnail(). -extern cairo_user_data_key_t fiv_io_key_text; - -/// The next frame in a sequence, as a surface, in a chain, pre-composited. -/// There is no wrap-around. -extern cairo_user_data_key_t fiv_io_key_frame_next; -/// The previous frame in a sequence, as a surface, in a chain, pre-composited. -/// This is a weak pointer that wraps around, and needn't be present -/// for static images. -extern cairo_user_data_key_t fiv_io_key_frame_previous; -/// Frame duration in milliseconds as an intptr_t. -extern cairo_user_data_key_t fiv_io_key_frame_duration; -/// How many times to repeat the animation, or zero for +inf, as a uintptr_t. -extern cairo_user_data_key_t fiv_io_key_loops; - -/// The first frame of the next page, as a surface, in a chain. -/// There is no wrap-around. -extern cairo_user_data_key_t fiv_io_key_page_next; -/// The first frame of the previous page, as a surface, in a chain. -/// There is no wrap-around. This is a weak pointer. -extern cairo_user_data_key_t fiv_io_key_page_previous; - -typedef struct _FivIoRenderClosure { +// https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf Table 6 +enum _FivIoOrientation { + FivIoOrientationUnknown = 0, + FivIoOrientation0 = 1, + FivIoOrientationMirror0 = 2, + FivIoOrientation180 = 3, + FivIoOrientationMirror180 = 4, + FivIoOrientationMirror270 = 5, + FivIoOrientation90 = 6, + FivIoOrientationMirror90 = 7, + FivIoOrientation270 = 8 +}; + +// TODO(p): Maybe make FivIoProfile a referencable type, +// then loaders could store it in their closures. +struct _FivIoRenderClosure { /// The rendering is allowed to fail, returning NULL. - cairo_surface_t *(*render)(struct _FivIoRenderClosure *, double scale); -} FivIoRenderClosure; + FivIoImage *(*render)( + FivIoRenderClosure *, FivIoCmm *, FivIoProfile *, double scale); + void (*destroy)(FivIoRenderClosure *); +}; -/// A FivIoRenderClosure for parametrized re-rendering of vector formats. -/// This is attached at the page level. -/// The rendered image will not have this key. -extern cairo_user_data_key_t fiv_io_key_render; +// Metadata are typically attached to all Cairo surfaces in an animation. -typedef struct { - const char *uri; ///< Source URI - FivIoProfile screen_profile; ///< Target colour space or NULL - int screen_dpi; ///< Target DPI - gboolean enhance; ///< Enhance JPEG (currently) - gboolean first_frame_only; ///< Only interested in the 1st frame - GPtrArray *warnings; ///< String vector for non-fatal errors -} FivIoOpenContext; +struct _FivIoImage { + uint8_t *data; ///< Raw image data + cairo_format_t format; ///< Data format + uint32_t width; ///< Width of the image in pixels + uint32_t stride; ///< Row stride in bytes + uint32_t height; ///< Height of the image in pixels -cairo_surface_t *fiv_io_open(const FivIoOpenContext *ctx, GError **error); -cairo_surface_t *fiv_io_open_from_data( - const char *data, size_t len, const FivIoOpenContext *ctx, GError **error); -cairo_surface_t *fiv_io_open_png_thumbnail(const char *path, GError **error); + FivIoOrientation orientation; ///< Orientation to use for display -// --- Thumbnail passing utilities --------------------------------------------- + GBytes *exif; ///< Raw Exif/TIFF segment + GBytes *icc; ///< Raw ICC profile data + GBytes *xmp; ///< Raw XMP data + GBytes *thum; ///< WebP THUM chunk, for our thumbnails -enum { FIV_IO_SERIALIZE_LOW_QUALITY = 1 << 0 }; + /// GHashTable with key-value pairs from PNG's tEXt, zTXt, iTXt chunks. + /// Currently only read by fiv_io_open_png_thumbnail(). + GHashTable *text; -void fiv_io_serialize_to_stdout(cairo_surface_t *surface, guint64 user_data); -cairo_surface_t *fiv_io_deserialize(GBytes *bytes, guint64 *user_data); + /// A FivIoRenderClosure for parametrized re-rendering of vector formats. + /// This is attached at the page level. + FivIoRenderClosure *render; -// --- Filesystem -------------------------------------------------------------- + /// The first frame of the next page, in a chain. + /// There is no wrap-around. + FivIoImage *page_next; -typedef enum _FivIoModelSort { - FIV_IO_MODEL_SORT_NAME, - FIV_IO_MODEL_SORT_MTIME, - FIV_IO_MODEL_SORT_COUNT, + /// The first frame of the previous page, in a chain. + /// There is no wrap-around. This is a weak pointer. + FivIoImage *page_previous; - FIV_IO_MODEL_SORT_MIN = 0, - FIV_IO_MODEL_SORT_MAX = FIV_IO_MODEL_SORT_COUNT - 1 -} FivIoModelSort; + /// The next frame in a sequence, in a chain, pre-composited. + /// There is no wrap-around. + FivIoImage *frame_next; -#define FIV_TYPE_IO_MODEL (fiv_io_model_get_type()) -G_DECLARE_FINAL_TYPE(FivIoModel, fiv_io_model, FIV, IO_MODEL, GObject) + /// The previous frame in a sequence, in a chain, pre-composited. + /// This is a weak pointer that wraps around, + /// and needn't be present for static images. + FivIoImage *frame_previous; -/// Loads a directory. Clears itself even on failure. -gboolean fiv_io_model_open(FivIoModel *self, GFile *directory, GError **error); + /// Frame duration in milliseconds. + int64_t frame_duration; -/// Returns the current location as a GFile. -/// There is no ownership transfer, and the object may be NULL. -GFile *fiv_io_model_get_location(FivIoModel *self); + /// How many times to repeat the animation, or zero for +inf. + uint64_t loops; +}; -typedef struct { - gchar *uri; ///< GIO URI - gchar *target_uri; ///< GIO URI for any target - gchar *collate_key; ///< Collate key for the filename - gint64 mtime_msec; ///< Modification time in milliseconds -} FivIoModelEntry; +FivIoImage *fiv_io_image_ref(FivIoImage *image); +void fiv_io_image_unref(FivIoImage *image); -const FivIoModelEntry *fiv_io_model_get_files(FivIoModel *self, gsize *len); -const FivIoModelEntry *fiv_io_model_get_subdirs(FivIoModel *self, gsize *len); +/// Analogous to cairo_image_surface_create(). May return NULL. +FivIoImage *fiv_io_image_new( + cairo_format_t format, uint32_t width, uint32_t height); -// --- Export ------------------------------------------------------------------ +/// Return a new Cairo image surface referencing the same data as the image, +/// eating the reference to it. +cairo_surface_t *fiv_io_image_to_surface(FivIoImage *image); -/// Encodes a Cairo surface as a WebP bitstream, following the configuration. -/// The result needs to be freed using WebPFree/WebPDataClear(). -unsigned char *fiv_io_encode_webp( - cairo_surface_t *surface, const WebPConfig *config, size_t *len); +/// Return a new Cairo image surface referencing the same data as the image, +/// without eating the image's reference. +cairo_surface_t *fiv_io_image_to_surface_noref(const FivIoImage *image); -/// Saves the page as a lossless WebP still picture or animation. -/// If no exact frame is specified, this potentially creates an animation. -gboolean fiv_io_save(cairo_surface_t *page, cairo_surface_t *frame, - FivIoProfile target, const char *path, GError **error); +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// --- Metadata ---------------------------------------------------------------- +typedef struct { + const char *uri; ///< Source URI + FivIoCmm *cmm; ///< Colour management module or NULL + FivIoProfile *screen_profile; ///< Target colour space or NULL + int screen_dpi; ///< Target DPI + gboolean enhance; ///< Enhance JPEG (currently) + gboolean first_frame_only; ///< Only interested in the 1st frame + GPtrArray *warnings; ///< String vector for non-fatal errors +} FivIoOpenContext; -// https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf Table 6 -typedef enum _FivIoOrientation { - FivIoOrientationUnknown = 0, - FivIoOrientation0 = 1, - FivIoOrientationMirror0 = 2, - FivIoOrientation180 = 3, - FivIoOrientationMirror180 = 4, - FivIoOrientationMirror270 = 5, - FivIoOrientation90 = 6, - FivIoOrientationMirror90 = 7, - FivIoOrientation270 = 8 -} FivIoOrientation; +FivIoImage *fiv_io_open(const FivIoOpenContext *ctx, GError **error); +FivIoImage *fiv_io_open_from_data( + const char *data, size_t len, const FivIoOpenContext *ctx, GError **error); -/// Returns a rendering matrix for a surface (user space to pattern space), +FivIoImage *fiv_io_open_png_thumbnail(const char *path, GError **error); + +// --- Metadata ---------------------------------------------------------------- + +/// Returns a rendering matrix for an image (user space to pattern space), /// and its target dimensions. -cairo_matrix_t fiv_io_orientation_apply(cairo_surface_t *surface, +cairo_matrix_t fiv_io_orientation_apply(const FivIoImage *image, FivIoOrientation orientation, double *width, double *height); -void fiv_io_orientation_dimensions(cairo_surface_t *surface, +cairo_matrix_t fiv_io_orientation_matrix( + FivIoOrientation orientation, double width, double height); +void fiv_io_orientation_dimensions(const FivIoImage *image, FivIoOrientation orientation, double *width, double *height); /// Extracts the orientation field from Exif, if there's any. @@ -177,4 +195,25 @@ FivIoOrientation fiv_io_exif_orientation(const guint8 *exif, gsize len); /// Save metadata attached by this module in Exiv2 format. gboolean fiv_io_save_metadata( - cairo_surface_t *page, const char *path, GError **error); + const FivIoImage *page, const char *path, GError **error); + +// --- Thumbnail passing utilities --------------------------------------------- + +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); + +// --- Export ------------------------------------------------------------------ + +/// Encodes an image as a WebP bitstream, following the configuration. +/// The result needs to be freed using WebPFree/WebPDataClear(). +unsigned char *fiv_io_encode_webp( + FivIoImage *image, const WebPConfig *config, size_t *len); + +/// Saves the page as a lossless WebP still picture or animation. +/// If no exact frame is specified, this potentially creates an animation. +gboolean fiv_io_save(FivIoImage *page, FivIoImage *frame, + FivIoProfile *target, const char *path, GError **error); diff --git a/fiv-jpegcrop.c b/fiv-jpegcrop.c index f19ff74..d8bdb9c 100644 --- a/fiv-jpegcrop.c +++ b/fiv-jpegcrop.c @@ -18,6 +18,9 @@ #include <gtk/gtk.h> #include <turbojpeg.h> +#include <stdlib.h> +#include <string.h> + #include "config.h" // --- Utilities --------------------------------------------------------------- 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-sidebar.c b/fiv-sidebar.c index fc63a99..900c8a8 100644 --- a/fiv-sidebar.c +++ b/fiv-sidebar.c @@ -25,7 +25,6 @@ struct _FivSidebar { GtkScrolledWindow parent_instance; GtkPlacesSidebar *places; - GtkWidget *toolbar; GtkWidget *listbox; FivIoModel *model; }; @@ -78,7 +77,7 @@ fiv_sidebar_class_init(FivSidebarClass *klass) // You're giving me no choice, Adwaita. // Your style is hardcoded to match against the class' CSS name. - // And I need replicate the internal widget structure. + // And I need to replicate the internal widget structure. gtk_widget_class_set_css_name(widget_class, "placessidebar"); // TODO(p): Consider a return value, and using it. @@ -313,28 +312,9 @@ on_update_task_done(GObject *source_object, G_GNUC_UNUSED GAsyncResult *res, } static void -update_location(FivSidebar *self) +reload_directories(FivSidebar *self) { GFile *location = fiv_io_model_get_location(self->model); - - GFile *collection = g_file_new_for_uri(FIV_COLLECTION_SCHEME ":/"); - gtk_places_sidebar_remove_shortcut(self->places, collection); - if (location && g_file_has_uri_scheme(location, FIV_COLLECTION_SCHEME)) { - // add_shortcut() asynchronously requests GFileInfo, and only fills in - // the new row's "uri" data field once that's finished, resulting in - // the immediate set_location() call below failing to find it. - gtk_places_sidebar_add_shortcut(self->places, collection); - - // Queue up a callback using the same mechanism that GFile uses. - GTask *task = g_task_new(self, NULL, on_update_task_done, NULL); - g_task_set_name(task, __func__); - g_task_set_priority(task, G_PRIORITY_LOW); - g_task_run_in_thread(task, on_update_task); - g_object_unref(task); - } - g_object_unref(collection); - - gtk_places_sidebar_set_location(self->places, location); gtk_container_foreach(GTK_CONTAINER(self->listbox), (GtkCallback) gtk_widget_destroy, NULL); if (!location) @@ -358,10 +338,10 @@ update_location(FivSidebar *self) gtk_container_add(GTK_CONTAINER(self->listbox), row); gsize len = 0; - const FivIoModelEntry *subdirs = + FivIoModelEntry *const *subdirs = fiv_io_model_get_subdirs(self->model, &len); for (gsize i = 0; i < len; i++) { - GFile *file = g_file_new_for_uri(subdirs[i].uri); + GFile *file = g_file_new_for_uri(subdirs[i]->uri); if ((row = create_row(self, file, "go-down-symbolic"))) gtk_container_add(GTK_CONTAINER(self->listbox), row); g_object_unref(file); @@ -369,6 +349,41 @@ update_location(FivSidebar *self) } static void +on_model_subdirectories_changed(G_GNUC_UNUSED FivIoModel *model, + FivIoModelEntry *old, FivIoModelEntry *new, gpointer user_data) +{ + FivSidebar *self = FIV_SIDEBAR(user_data); + // TODO(p): Optimize: there's no need to update parent directories. + if (!old || !new || strcmp(old->uri, new->uri)) + reload_directories(self); +} + +static void +update_location(FivSidebar *self) +{ + GFile *location = fiv_io_model_get_location(self->model); + GFile *collection = g_file_new_for_uri(FIV_COLLECTION_SCHEME ":/"); + gtk_places_sidebar_remove_shortcut(self->places, collection); + if (location && g_file_has_uri_scheme(location, FIV_COLLECTION_SCHEME)) { + // add_shortcut() asynchronously requests GFileInfo, and only fills in + // the new row's "uri" data field once that's finished, resulting in + // the immediate set_location() call below failing to find it. + gtk_places_sidebar_add_shortcut(self->places, collection); + + // Queue up a callback using the same mechanism that GFile uses. + GTask *task = g_task_new(self, NULL, on_update_task_done, NULL); + g_task_set_name(task, __func__); + g_task_set_priority(task, G_PRIORITY_LOW); + g_task_run_in_thread(task, on_update_task); + g_object_unref(task); + } + g_object_unref(collection); + + gtk_places_sidebar_set_location(self->places, location); + reload_directories(self); +} + +static void on_open_breadcrumb( G_GNUC_UNUSED GtkListBox *listbox, GtkListBoxRow *row, gpointer user_data) { @@ -418,7 +433,10 @@ complete_path(GFile *location, GtkListStore *model) !info) break; - if (g_file_info_get_file_type(info) != G_FILE_TYPE_DIRECTORY || + if (g_file_info_get_file_type(info) != G_FILE_TYPE_DIRECTORY) + continue; + if (g_file_info_has_attribute(info, + G_FILE_ATTRIBUTE_STANDARD_IS_HIDDEN) && g_file_info_get_is_hidden(info)) continue; @@ -583,12 +601,6 @@ fiv_sidebar_init(FivSidebar *self) gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(self->places), GTK_POLICY_NEVER, GTK_POLICY_NEVER); - // None of GtkActionBar, GtkToolbar, .inline-toolbar is appropriate. - // It is either side-favouring borders or excess button padding. - self->toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); - gtk_style_context_add_class( - gtk_widget_get_style_context(self->toolbar), GTK_STYLE_CLASS_TOOLBAR); - self->listbox = gtk_list_box_new(); gtk_list_box_set_selection_mode( GTK_LIST_BOX(self->listbox), GTK_SELECTION_NONE); @@ -603,10 +615,6 @@ fiv_sidebar_init(FivSidebar *self) gtk_container_add( GTK_CONTAINER(superbox), gtk_separator_new(GTK_ORIENTATION_VERTICAL)); gtk_container_add( - GTK_CONTAINER(superbox), self->toolbar); - gtk_container_add( - GTK_CONTAINER(superbox), gtk_separator_new(GTK_ORIENTATION_VERTICAL)); - gtk_container_add( GTK_CONTAINER(superbox), self->listbox); gtk_container_add(GTK_CONTAINER(self), superbox); @@ -634,10 +642,11 @@ fiv_sidebar_new(FivIoModel *model) gtk_container_set_focus_vadjustment(GTK_CONTAINER(sidebar_port), gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(self))); - // TODO(p): There should be an extra signal to watch location changes only. self->model = g_object_ref(model); - g_signal_connect_swapped(self->model, "subdirectories-changed", + g_signal_connect_swapped(self->model, "reloaded", G_CALLBACK(update_location), self); + g_signal_connect(self->model, "subdirectories-changed", + G_CALLBACK(on_model_subdirectories_changed), self); return GTK_WIDGET(self); } @@ -648,10 +657,3 @@ fiv_sidebar_show_enter_location(FivSidebar *self) g_return_if_fail(FIV_IS_SIDEBAR(self)); g_signal_emit_by_name(self->places, "show-enter-location"); } - -GtkBox * -fiv_sidebar_get_toolbar(FivSidebar *self) -{ - g_return_val_if_fail(FIV_IS_SIDEBAR(self), NULL); - return GTK_BOX(self->toolbar); -} diff --git a/fiv-sidebar.h b/fiv-sidebar.h index 2d0888a..0cea059 100644 --- a/fiv-sidebar.h +++ b/fiv-sidebar.h @@ -17,7 +17,7 @@ #pragma once -#include "fiv-io.h" +#include "fiv-io-model.h" #include <gtk/gtk.h> @@ -26,4 +26,3 @@ G_DECLARE_FINAL_TYPE(FivSidebar, fiv_sidebar, FIV, SIDEBAR, GtkScrolledWindow) GtkWidget *fiv_sidebar_new(FivIoModel *model); void fiv_sidebar_show_enter_location(FivSidebar *self); -GtkBox *fiv_sidebar_get_toolbar(FivSidebar *self); diff --git a/fiv-thumbnail.c b/fiv-thumbnail.c index f95ed70..fffbac7 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 <p@janouch.name> +// Copyright (c) 2021 - 2023, 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. @@ -33,6 +33,9 @@ #ifdef HAVE_LIBRAW #include <libraw.h> +#if LIBRAW_VERSION >= LIBRAW_MAKE_VERSION(0, 21, 0) +#define LIBRAW_OPIONS_NO_MEMERR_CALLBACK 0 +#endif #endif // HAVE_LIBRAW // TODO(p): Consider merging back with fiv-io. @@ -97,7 +100,16 @@ mark_thumbnail_lq(cairo_surface_t *surface) static gchar * fiv_thumbnail_get_root(void) { +#ifdef G_OS_WIN32 + // We can do better than GLib with FOLDERID_InternetCache, + // and we don't want to place .cache directly in the user's home. + // TODO(p): Register this thumbnail path using the installer: + // https://learn.microsoft.com/en-us/windows/win32/lwef/disk-cleanup + gchar *cache_dir = + g_build_filename(g_get_user_data_dir(), PROJECT_NAME, NULL); +#else gchar *cache_dir = get_xdg_home_dir("XDG_CACHE_HOME", ".cache"); +#endif gchar *thumbnails_dir = g_build_filename(cache_dir, "thumbnails", NULL); g_free(cache_dir); return thumbnails_dir; @@ -122,35 +134,37 @@ might_be_a_thumbnail(const char *path_or_uri) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static cairo_surface_t * +static FivIoImage * render(GFile *target, GBytes *data, gboolean *color_managed, GError **error) { + FivIoCmm *cmm = fiv_io_cmm_get_default(); FivIoOpenContext ctx = { .uri = g_file_get_uri(target), - .screen_profile = fiv_io_profile_new_sRGB(), + // Remember to synchronize changes with adjust_thumbnail(). + .cmm = cmm, + .screen_profile = fiv_io_cmm_get_profile_sRGB(cmm), .screen_dpi = 96, .first_frame_only = TRUE, // Only using this array as a redirect. .warnings = g_ptr_array_new_with_free_func(g_free), }; - cairo_surface_t *surface = fiv_io_open_from_data( + FivIoImage *image = fiv_io_open_from_data( g_bytes_get_data(data, NULL), g_bytes_get_size(data), &ctx, error); g_free((gchar *) ctx.uri); g_ptr_array_free(ctx.warnings, TRUE); if ((*color_managed = !!ctx.screen_profile)) fiv_io_profile_free(ctx.screen_profile); g_bytes_unref(data); - return surface; + return image; } // In principle similar to rescale_thumbnail() from fiv-browser.c. -static cairo_surface_t * -adjust_thumbnail(cairo_surface_t *thumbnail, double row_height) +static FivIoImage * +adjust_thumbnail(FivIoImage *thumbnail, double row_height) { // Hardcode orientation. - FivIoOrientation orientation = (uintptr_t) cairo_surface_get_user_data( - thumbnail, &fiv_io_key_orientation); + FivIoOrientation orientation = thumbnail->orientation; double w = 0, h = 0; cairo_matrix_t matrix = @@ -167,33 +181,46 @@ adjust_thumbnail(cairo_surface_t *thumbnail, double row_height) } // Vector images should not have orientation, this should handle them all. - FivIoRenderClosure *closure = - cairo_surface_get_user_data(thumbnail, &fiv_io_key_render); + FivIoRenderClosure *closure = thumbnail->render; if (closure && orientation <= FivIoOrientation0) { + // Remember to synchronize changes with render(). + FivIoCmm *cmm = fiv_io_cmm_get_default(); + FivIoProfile *screen_profile = fiv_io_cmm_get_profile_sRGB(cmm); // This API doesn't accept non-uniform scaling; prefer a vertical fit. - cairo_surface_t *scaled = closure->render(closure, scale_y); + FivIoImage *scaled = + closure->render(closure, cmm, screen_profile, scale_y); + if (screen_profile) + fiv_io_profile_free(screen_profile); if (scaled) return scaled; } - // This will be CAIRO_FORMAT_INVALID with non-image surfaces, which is fine. - cairo_format_t format = cairo_image_surface_get_format(thumbnail); - if (format != CAIRO_FORMAT_INVALID && - orientation <= FivIoOrientation0 && scale_x == 1 && scale_y == 1) - return cairo_surface_reference(thumbnail); + if (orientation <= FivIoOrientation0 && scale_x == 1 && scale_y == 1) + return fiv_io_image_ref(thumbnail); + cairo_format_t format = thumbnail->format; int projected_width = round(scale_x * w); int projected_height = round(scale_y * h); - cairo_surface_t *scaled = cairo_image_surface_create( + FivIoImage *scaled = fiv_io_image_new( (format == CAIRO_FORMAT_RGB24 || format == CAIRO_FORMAT_RGB30) ? CAIRO_FORMAT_RGB24 : CAIRO_FORMAT_ARGB32, projected_width, projected_height); + if (!scaled) { + g_warning("image allocation failure"); + return fiv_io_image_ref(thumbnail); + } + + cairo_surface_t *surface = fiv_io_image_to_surface_noref(scaled); + cairo_t *cr = cairo_create(surface); + cairo_surface_destroy(surface); - cairo_t *cr = cairo_create(scaled); cairo_scale(cr, scale_x, scale_y); - cairo_set_source_surface(cr, thumbnail, 0, 0); + surface = fiv_io_image_to_surface_noref(thumbnail); + cairo_set_source_surface(cr, surface, 0, 0); + cairo_surface_destroy(surface); + cairo_pattern_t *pattern = cairo_get_source(cr); // CAIRO_FILTER_BEST, for some reason, works bad with CAIRO_FORMAT_RGB30. cairo_pattern_set_filter(pattern, CAIRO_FILTER_GOOD); @@ -205,9 +232,7 @@ adjust_thumbnail(cairo_surface_t *thumbnail, double row_height) // Note that this doesn't get triggered with oversize input surfaces, // even though nothing will be rendered. - if (cairo_surface_status(thumbnail) != CAIRO_STATUS_SUCCESS || - cairo_surface_status(scaled) != CAIRO_STATUS_SUCCESS || - cairo_pattern_status(pattern) != CAIRO_STATUS_SUCCESS || + if (cairo_pattern_status(pattern) != CAIRO_STATUS_SUCCESS || cairo_status(cr) != CAIRO_STATUS_SUCCESS) g_warning("thumbnail scaling failed"); @@ -215,149 +240,325 @@ adjust_thumbnail(cairo_surface_t *thumbnail, double row_height) return scaled; } -static cairo_surface_t * -orient_thumbnail(cairo_surface_t *surface, FivIoOrientation orientation) +static FivIoImage * +orient_thumbnail(FivIoImage *image) { - if (!surface || orientation <= FivIoOrientation0) - return surface; + if (image->orientation <= FivIoOrientation0) + return image; double w = 0, h = 0; cairo_matrix_t matrix = - fiv_io_orientation_apply(surface, orientation, &w, &h); - cairo_surface_t *oriented = - cairo_image_surface_create(CAIRO_FORMAT_RGB24, w, h); + fiv_io_orientation_apply(image, image->orientation, &w, &h); + FivIoImage *oriented = fiv_io_image_new(image->format, w, h); + if (!oriented) { + g_warning("image allocation failure"); + return image; + } - cairo_t *cr = cairo_create(oriented); + cairo_surface_t *surface = fiv_io_image_to_surface_noref(oriented); + cairo_t *cr = cairo_create(surface); + cairo_surface_destroy(surface); + + surface = fiv_io_image_to_surface(image); cairo_set_source_surface(cr, surface, 0, 0); + cairo_surface_destroy(surface); cairo_pattern_set_matrix(cairo_get_source(cr), &matrix); cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); cairo_paint(cr); cairo_destroy(cr); - cairo_surface_destroy(surface); return oriented; } -cairo_surface_t * -fiv_thumbnail_extract(GFile *target, FivThumbnailSize max_size, GError **error) +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +#ifdef HAVE_LIBRAW +#if LIBRAW_VERSION >= LIBRAW_MAKE_VERSION(0, 21, 0) + +static int +extract_libraw_compare(const void *a, const void *b) { - const char *path = g_file_peek_path(target); - if (!path) { - set_error(error, "thumbnails will only be extracted from local files"); + const libraw_thumbnail_item_t **t1 = (const libraw_thumbnail_item_t **) a; + const libraw_thumbnail_item_t **t2 = (const libraw_thumbnail_item_t **) b; + float p1 = (float) (*t1)->twidth * (*t1)->theight; + float p2 = (float) (*t2)->twidth * (*t2)->theight; + return (p2 < p1) - (p1 < p2); +} + +static gboolean +extract_libraw_unpack(libraw_data_t *iprc, int *flip, GError **error) +{ + int count = iprc->thumbs_list.thumbcount; + if (count <= 0) { + set_error(error, "no thumbnails found"); + return FALSE; + } + + // The old libraw_unpack_thumb() goes for the largest thumbnail, + // but we currently want the smallest usable thumbnail. Order them. + libraw_thumbnail_item_t **sorted = g_malloc_n(count, sizeof *sorted); + for (int i = 0; i < count; i++) + sorted[i] = &iprc->thumbs_list.thumblist[i]; + qsort(sorted, count, sizeof *sorted, extract_libraw_compare); + + // With the raw.pixls.us database, zero dimensions occur in two cases: + // - when thumbcount should really be 0, + // - with the last, huge JPEG thumbnail in CR3 raws. + // The maintainer refuses to change anything about it (#589). + int i = 0; + while (i < count && (!sorted[i]->twidth || !sorted[i]->theight)) + i++; + + // Ignore thumbnails whose decoding is likely to be a waste of time. + // XXX: This primarily targets the TIFF/EP shortcut code, + // because decoding a thumbnail will always be /much/ quicker than a render. + // TODO(p): Maybe don't mark raw image thumbnails as low-quality + // if they're the right aspect ratio, and of sufficiently large size. + // The only downsides to camera-provided thumbnails seem to be cropping, + // and when they're decoded incorrectly. Also don't trust tflip. + float output_pixels = (float) iprc->sizes.iwidth * iprc->sizes.iheight; + // Note that the ratio may even be larger than 1, as seen with CR2 files. + while (i < count && + (float) sorted[count - 1]->twidth * sorted[count - 1]->theight > + output_pixels * 0.75) + count--; + + // The smallest size thumbnail is very often forced to be 4:3, + // and the remaining space is filled with black, looking quite wrong. + // It isn't really possible to strip those borders, because many are JPEGs. + // + // Another reason to skip thumbnails of mismatching aspect ratios is + // to avoid browser items from jumping around when low-quality thumbnails + // get replaced with their final versions. + // + // Note that some of them actually have borders on all four sides + // (Nikon/D50/DSC_5155.NEF, Nikon/D70/20170902_0047.NEF, + // Nikon/D70s/RAW_NIKON_D70S.NEF), or even on just one side + // (Leica/LEICA M MONOCHROM (Typ 246), Leica/M (Typ 240)). + // Another interesting possibility is Sony/DSC-HX99/DSC00001.ARW, + // where the correct-ratio thumbnail has borders but the main image doesn't. + // + // The problematic thumbnail is usually, but not always, sized 160x120, + // and some of them may actually be fine. + float output_ratio = (float) iprc->sizes.iwidth / iprc->sizes.iheight; + while (i < count) { + // XXX: tflip is less reliable than libraw_dcraw_make_mem_thumb() + // and reading out Orientation from the resulting Exif. + float ratio = sorted[i]->tflip == 5 || sorted[i]->tflip == 6 + ? (float) sorted[i]->theight / sorted[i]->twidth + : (float) sorted[i]->twidth / sorted[i]->theight; + if (fabsf(ratio - output_ratio) < 0.05) + break; + i++; + } + + // Avoid pink-tinted readouts of CR2 IFD2 (#590). + // + // This thumbnail can also have a black stripe on the left and the top, + // which we should remove if using fixed LibRaw > 0.21.1. + if (i < count && iprc->idata.maker_index == LIBRAW_CAMERAMAKER_Canon && + sorted[i]->tformat == LIBRAW_INTERNAL_THUMBNAIL_KODAK_THUMB) + i++; + + bool found = i != count; + if (found) + i = sorted[i] - iprc->thumbs_list.thumblist; + + g_free(sorted); + if (!found) { + set_error(error, "no suitable thumbnails found"); + return FALSE; + } + + int err = 0; + if ((err = libraw_unpack_thumb_ex(iprc, i))) { + set_error(error, libraw_strerror(err)); + return FALSE; + } + *flip = iprc->thumbs_list.thumblist[i].tflip; + return TRUE; +} + +#else // LIBRAW_VERSION < LIBRAW_MAKE_VERSION(0, 21, 0) + +static gboolean +extract_libraw_unpack(libraw_data_t *iprc, int *flip, GError **error) +{ + int err = 0; + if ((err = libraw_unpack_thumb(iprc))) { + set_error(error, libraw_strerror(err)); + return FALSE; + } + + // The main image's "flip" often matches up, but sometimes doesn't, e.g.: + // - Phase One/H 25/H25_Outdoor_.IIQ + // - Phase One/H 25/H25_IT8.7-2_Card.TIF + *flip = iprc->sizes.flip; + return TRUE; +} + +#endif // LIBRAW_VERSION < LIBRAW_MAKE_VERSION(0, 21, 0) + +// LibRaw does a weird permutation here, so follow the documentation, +// which assumes that mirrored orientations never happen. +static FivIoOrientation +extract_libraw_unflip(int flip) +{ + switch (flip) { + break; case 0: + return FivIoOrientation0; + break; case 3: + return FivIoOrientation180; + break; case 5: + return FivIoOrientation270; + break; case 6: + return FivIoOrientation90; + break; default: + return FivIoOrientationUnknown; + } +} + +static FivIoImage * +extract_libraw_bitmap(libraw_processed_image_t *image, int flip, GError **error) +{ + // Anything else is extremely rare. + if (image->colors != 3 || image->bits != 8) { + set_error(error, "unsupported bitmap thumbnail"); return NULL; } - GMappedFile *mf = g_mapped_file_new(path, FALSE, error); - if (!mf) + FivIoImage *I = fiv_io_image_new( + CAIRO_FORMAT_RGB24, image->width, image->height); + if (!I) { + set_error(error, "image allocation failure"); return NULL; + } - // Bitmap thumbnails generally need rotating, e.g.: - // - Hasselblad/H4D-50/2-9-2017_street_0012.fff - // - OnePlus/One/IMG_20150729_201116.dng (and more DNGs in general) - // Though it's apparent LibRaw doesn't adjust the thumbnails to match - // the main image's "flip" field (it just happens to match up often), e.g.: - // - Phase One/H 25/H25_Outdoor_.IIQ (correct Orientation in IFD0) - // - Phase One/H 25/H25_IT8.7-2_Card.TIF (correctly missing in IFD0) - // - // JPEG thumbnails generally have the right rotation in their Exif, e.g.: - // - Canon/EOS-1Ds Mark II/RAW_CANON_1DSM2.CR2 - // - Leica/C (Typ 112)/Leica_-_C_(Typ_112)-_3:2.RWL - // - Nikon/1 S2/RAW_NIKON_1S2.NEF - // - Panasonic/DMC-FZ18/RAW_PANASONIC_LUMIX_FZ18.RAW - // - Panasonic/DMC-FZ70/P1000836.RW2 - // - Samsung/NX200/2013-05-08-194524__sam6589.srw - // - Sony/DSC-HX95/DSC00018.ARW - // - // Some files are problematic and we won't bother with special-casing: - // - Leaf/Aptus 22/L_003172.mos (JPEG)'s thumbnail wrongly contains - // Exif Orientation 6, and sizes.flip also contains 6. - // - Nokia/Lumia 1020/RAW_NOKIA_LUMIA_1020.DNG (bitmap) has wrong color. - // - Ricoh/GXR/R0017428.DNG (JPEG) seems to be plainly invalid. - FivIoOrientation orientation = FivIoOrientationUnknown; - cairo_surface_t *surface = NULL; -#ifndef HAVE_LIBRAW - // TODO(p): Implement our own thumbnail extractors. - set_error(error, "unsupported file"); -#else // HAVE_LIBRAW + guint32 *out = (guint32 *) I->data; + const unsigned char *in = image->data; + for (guint64 i = 0; i < (guint64) image->width * image->height; in += 3) + out[i++] = in[0] << 16 | in[1] << 8 | in[2]; + + I->orientation = extract_libraw_unflip(flip); + return I; +} + +static FivIoImage * +extract_libraw(GFile *target, GMappedFile *mf, GError **error) +{ + FivIoImage *I = NULL; libraw_data_t *iprc = libraw_init( LIBRAW_OPIONS_NO_MEMERR_CALLBACK | LIBRAW_OPIONS_NO_DATAERR_CALLBACK); if (!iprc) { set_error(error, "failed to obtain a LibRaw handle"); - goto fail; + return NULL; } int err = 0; if ((err = libraw_open_buffer(iprc, (void *) g_mapped_file_get_contents(mf), - g_mapped_file_get_length(mf))) || - (err = libraw_unpack_thumb(iprc))) { + g_mapped_file_get_length(mf)))) { set_error(error, libraw_strerror(err)); - goto fail_libraw; + goto fail; } + if ((err = libraw_adjust_sizes_info_only(iprc))) { + set_error(error, libraw_strerror(err)); + goto fail; + } + + int flip = 0; + if (!extract_libraw_unpack(iprc, &flip, error)) + goto fail; libraw_processed_image_t *image = libraw_dcraw_make_mem_thumb(iprc, &err); if (!image) { set_error(error, libraw_strerror(err)); - goto fail_libraw; + goto fail; } - gboolean dummy = FALSE; + // Bitmap thumbnails generally need rotating, e.g.: + // - Hasselblad/H4D-50/2-9-2017_street_0012.fff + // - OnePlus/One/IMG_20150729_201116.dng (and more DNGs in general) + // + // JPEG thumbnails generally have the right rotation in their Exif, e.g.: + // - Canon/EOS-1Ds Mark II/RAW_CANON_1DSM2.CR2 + // - Leica/C (Typ 112)/Leica_-_C_(Typ_112)-_3:2.RWL + // - Nikon/1 S2/RAW_NIKON_1S2.NEF + // - Panasonic/DMC-FZ18/RAW_PANASONIC_LUMIX_FZ18.RAW + // - Panasonic/DMC-FZ70/P1000836.RW2 + // - Samsung/NX200/2013-05-08-194524__sam6589.srw + // - Sony/DSC-HX95/DSC00018.ARW + // Note that LibRaw inserts its own Exif segment if it doesn't find one, + // and this may differ from flip. It may also be wrong, as in: + // - Leaf/Aptus 22/L_003172.mos + // + // Some files are problematic and we won't bother with special-casing: + // - Nokia/Lumia 1020/RAW_NOKIA_LUMIA_1020.DNG (bitmap) has wrong color. + // - Ricoh/GXR/R0017428.DNG (JPEG) seems to be plainly invalid. switch (image->type) { + gboolean dummy; case LIBRAW_IMAGE_JPEG: - surface = render( + I = render( target, g_bytes_new(image->data, image->data_size), &dummy, error); - orientation = (int) (intptr_t) cairo_surface_get_user_data( - surface, &fiv_io_key_orientation); break; case LIBRAW_IMAGE_BITMAP: - // Anything else is extremely rare. - if (image->colors != 3 || image->bits != 8) { - set_error(error, "unsupported bitmap thumbnail"); - break; - } - - surface = cairo_image_surface_create( - CAIRO_FORMAT_RGB24, image->width, image->height); - guint32 *out = (guint32 *) cairo_image_surface_get_data(surface); - const unsigned char *in = image->data; - for (guint64 i = 0; i < image->width * image->height; in += 3) - out[i++] = in[0] << 16 | in[1] << 8 | in[2]; - cairo_surface_mark_dirty(surface); - - // LibRaw actually turns an 8 to 5, so follow the documentation. - switch (iprc->sizes.flip) { - break; case 3: orientation = FivIoOrientation180; - break; case 5: orientation = FivIoOrientation270; - break; case 6: orientation = FivIoOrientation90; - } + I = extract_libraw_bitmap(image, flip, error); break; default: set_error(error, "unsupported embedded thumbnail"); } libraw_dcraw_clear_mem(image); -fail_libraw: +fail: libraw_close(iprc); + return I; +} + #endif // HAVE_LIBRAW -fail: +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +cairo_surface_t * +fiv_thumbnail_extract(GFile *target, FivThumbnailSize max_size, GError **error) +{ + const char *path = g_file_peek_path(target); + if (!path) { + set_error(error, "thumbnails will only be extracted from local files"); + return NULL; + } + + GMappedFile *mf = g_mapped_file_new(path, FALSE, error); + if (!mf) + return NULL; + + // In this case, g_mapped_file_get_contents() returns NULL, causing issues. + if (!g_mapped_file_get_length(mf)) { + set_error(error, "empty file"); + return NULL; + } + + FivIoImage *image = NULL; +#ifdef HAVE_LIBRAW + image = extract_libraw(target, mf, error); +#else // ! HAVE_LIBRAW + // TODO(p): Implement our own thumbnail extractors. + set_error(error, "unsupported file"); +#endif // ! HAVE_LIBRAW g_mapped_file_unref(mf); - // This hardcodes Exif orientation before adjust_thumbnail() might do so, - // before the early return below. - surface = orient_thumbnail(surface, orientation); - if (!surface || max_size < FIV_THUMBNAIL_SIZE_MIN || - max_size > FIV_THUMBNAIL_SIZE_MAX) - return surface; + if (!image) + return NULL; + if (max_size < FIV_THUMBNAIL_SIZE_MIN || max_size > FIV_THUMBNAIL_SIZE_MAX) + return fiv_io_image_to_surface(orient_thumbnail(image)); - cairo_surface_t *result = - adjust_thumbnail(surface, fiv_thumbnail_sizes[max_size].size); - cairo_surface_destroy(surface); - return result; + FivIoImage *result = + adjust_thumbnail(image, fiv_thumbnail_sizes[max_size].size); + fiv_io_image_unref(image); + return fiv_io_image_to_surface(result); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - static WebPData -encode_thumbnail(cairo_surface_t *surface) +encode_thumbnail(FivIoImage *image) { WebPData bitstream = {}; WebPConfig config = {}; @@ -369,12 +570,12 @@ encode_thumbnail(cairo_surface_t *surface) if (!WebPValidateConfig(&config)) return bitstream; - bitstream.bytes = fiv_io_encode_webp(surface, &config, &bitstream.size); + bitstream.bytes = fiv_io_encode_webp(image, &config, &bitstream.size); return bitstream; } static void -save_thumbnail(cairo_surface_t *thumbnail, const char *path, GString *thum) +save_thumbnail(FivIoImage *thumbnail, const char *path, GString *thum) { WebPMux *mux = WebPMuxNew(); WebPData bitstream = encode_thumbnail(thumbnail); @@ -418,9 +619,33 @@ 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; + FivIoImage *image = render(target, data, &color_managed, error); + if (!image) + return NULL; + + // TODO(p): Might want to keep this a square. + FivIoImage *result = + adjust_thumbnail(image, fiv_thumbnail_sizes[max_size].size); + fiv_io_image_unref(image); + return fiv_io_image_to_surface(result); +} + static cairo_surface_t * produce_fallback(GFile *target, FivThumbnailSize size, GError **error) { + // Note that this comes with a TOCTTOU problem. goffset filesize = 0; GFileInfo *info = g_file_query_info(target, G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_SIZE, @@ -442,21 +667,21 @@ produce_fallback(GFile *target, FivThumbnailSize size, GError **error) return NULL; gboolean color_managed = FALSE; - cairo_surface_t *surface = render(target, data, &color_managed, error); - if (!surface) + FivIoImage *image = render(target, data, &color_managed, error); + if (!image) return NULL; - cairo_surface_t *result = - adjust_thumbnail(surface, fiv_thumbnail_sizes[size].size); - cairo_surface_destroy(surface); - return result; + FivIoImage *result = + adjust_thumbnail(image, fiv_thumbnail_sizes[size].size); + fiv_io_image_unref(image); + return fiv_io_image_to_surface(result); } 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. @@ -471,6 +696,13 @@ fiv_thumbnail_produce(GFile *target, FivThumbnailSize max_size, GError **error) return NULL; } + // TODO(p): Use open(O_RDONLY | O_NONBLOCK | _O_BINARY), fstat(), + // g_mapped_file_new_from_fd(), and reset the non-blocking flag on the file. + if (!S_ISREG(st.st_mode)) { + set_error(error, "not a regular file"); + return NULL; + } + GError *e = NULL; GMappedFile *mf = g_mapped_file_new(path, FALSE, &e); if (!mf) { @@ -479,12 +711,18 @@ fiv_thumbnail_produce(GFile *target, FivThumbnailSize max_size, GError **error) return produce_fallback(target, max_size, error); } + // In this case, g_mapped_file_get_bytes() has NULL data, causing issues. gsize filesize = g_mapped_file_get_length(mf); + if (!filesize) { + set_error(error, "empty file"); + return NULL; + } + gboolean color_managed = FALSE; - cairo_surface_t *surface = + FivIoImage *image = render(target, g_mapped_file_get_bytes(mf), &color_managed, error); g_mapped_file_unref(mf); - if (!surface) + if (!image) return NULL; // Boilerplate copied from fiv_thumbnail_lookup(). @@ -498,14 +736,12 @@ fiv_thumbnail_produce(GFile *target, FivThumbnailSize max_size, GError **error) g_string_append_printf( thum, "%s%c%ld%c", THUMB_MTIME, 0, (long) st.st_mtime, 0); g_string_append_printf( - thum, "%s%c%ld%c", THUMB_SIZE, 0, (long) filesize, 0); + thum, "%s%c%llu%c", THUMB_SIZE, 0, (unsigned long long) filesize, 0); - if (cairo_surface_get_type(surface) == CAIRO_SURFACE_TYPE_IMAGE) { - g_string_append_printf(thum, "%s%c%d%c", THUMB_IMAGE_WIDTH, 0, - cairo_image_surface_get_width(surface), 0); - g_string_append_printf(thum, "%s%c%d%c", THUMB_IMAGE_HEIGHT, 0, - cairo_image_surface_get_height(surface), 0); - } + g_string_append_printf(thum, "%s%c%u%c", THUMB_IMAGE_WIDTH, 0, + (unsigned) image->width, 0); + g_string_append_printf(thum, "%s%c%u%c", THUMB_IMAGE_HEIGHT, 0, + (unsigned) image->height, 0); // Without a CMM, no conversion is attempted. if (color_managed) { @@ -513,19 +749,19 @@ fiv_thumbnail_produce(GFile *target, FivThumbnailSize max_size, GError **error) thum, "%s%c%s%c", THUMB_COLORSPACE, 0, THUMB_COLORSPACE_SRGB, 0); } - cairo_surface_t *max_size_surface = NULL; + FivIoImage *max_size_image = NULL; for (int use = max_size; use >= FIV_THUMBNAIL_SIZE_MIN; use--) { - cairo_surface_t *scaled = - adjust_thumbnail(surface, fiv_thumbnail_sizes[use].size); + FivIoImage *scaled = + adjust_thumbnail(image, fiv_thumbnail_sizes[use].size); gchar *path = g_strdup_printf("%s/wide-%s/%s.webp", thumbnails_dir, fiv_thumbnail_sizes[use].thumbnail_spec_name, sum); save_thumbnail(scaled, path, thum); g_free(path); - if (!max_size_surface) - max_size_surface = scaled; + if (!max_size_image) + max_size_image = scaled; else - cairo_surface_destroy(scaled); + fiv_io_image_unref(scaled); } g_string_free(thum, TRUE); @@ -533,13 +769,20 @@ fiv_thumbnail_produce(GFile *target, FivThumbnailSize max_size, GError **error) g_free(thumbnails_dir); g_free(sum); g_free(uri); - cairo_surface_destroy(surface); - return max_size_surface; + fiv_io_image_unref(image); + return fiv_io_image_to_surface(max_size_image); } +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +typedef struct { + const char *uri; ///< Target URI + time_t mtime; ///< File modification time + guint64 size; ///< File size +} Stat; + static bool -check_wide_thumbnail_texts(GBytes *thum, const char *target, time_t mtime, - bool *sRGB) +check_wide_thumbnail_texts(GBytes *thum, const Stat *st, bool *sRGB) { gsize len = 0; const gchar *s = g_bytes_get_data(thum, &len), *end = s + len; @@ -553,11 +796,14 @@ check_wide_thumbnail_texts(GBytes *thum, const char *target, time_t mtime, continue; } else if (!strcmp(key, THUMB_URI)) { have_uri = true; - if (strcmp(target, s)) + if (strcmp(st->uri, s)) return false; } else if (!strcmp(key, THUMB_MTIME)) { have_mtime = true; - if (atol(s) != mtime) + if (atol(s) != st->mtime) + return false; + } else if (!strcmp(key, THUMB_SIZE)) { + if (strtoull(s, NULL, 10) != st->size) return false; } else if (!strcmp(key, THUMB_COLORSPACE)) *sRGB = !strcmp(s, THUMB_COLORSPACE_SRGB); @@ -568,30 +814,29 @@ check_wide_thumbnail_texts(GBytes *thum, const char *target, time_t mtime, } static cairo_surface_t * -read_wide_thumbnail( - const char *path, const char *uri, time_t mtime, GError **error) +read_wide_thumbnail(const char *path, const Stat *st, GError **error) { gchar *thumbnail_uri = g_filename_to_uri(path, NULL, error); if (!thumbnail_uri) return NULL; - cairo_surface_t *surface = + FivIoImage *image = fiv_io_open(&(FivIoOpenContext){.uri = thumbnail_uri}, error); g_free(thumbnail_uri); - if (!surface) + if (!image) return NULL; bool sRGB = false; - GBytes *thum = cairo_surface_get_user_data(surface, &fiv_io_key_thum); - if (!thum) { + if (!image->thum) { g_clear_error(error); set_error(error, "not a thumbnail"); - } else if (!check_wide_thumbnail_texts(thum, uri, mtime, &sRGB)) { + } else if (!check_wide_thumbnail_texts(image->thum, st, &sRGB)) { g_clear_error(error); set_error(error, "mismatch"); } else { // TODO(p): Add a function or a non-valueless define to check // for CMM presence, then remove this ifdef. + cairo_surface_t *surface = fiv_io_image_to_surface(image); #ifdef HAVE_LCMS2 if (!sRGB) mark_thumbnail_lq(surface); @@ -599,24 +844,21 @@ read_wide_thumbnail( return surface; } - cairo_surface_destroy(surface); + fiv_io_image_unref(image); return NULL; } -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - static cairo_surface_t * -read_png_thumbnail( - const char *path, const char *uri, time_t mtime, GError **error) +read_png_thumbnail(const char *path, const Stat *st, GError **error) { - cairo_surface_t *surface = fiv_io_open_png_thumbnail(path, error); - if (!surface) + FivIoImage *image = fiv_io_open_png_thumbnail(path, error); + if (!image) return NULL; - GHashTable *texts = cairo_surface_get_user_data(surface, &fiv_io_key_text); + GHashTable *texts = image->text; if (!texts) { set_error(error, "not a thumbnail"); - cairo_surface_destroy(surface); + fiv_io_image_unref(image); return NULL; } @@ -624,18 +866,27 @@ read_png_thumbnail( // but those aren't interesting currently (would be for fast previews). const char *text_uri = g_hash_table_lookup(texts, THUMB_URI); const char *text_mtime = g_hash_table_lookup(texts, THUMB_MTIME); - if (!text_uri || strcmp(text_uri, uri) || - !text_mtime || atol(text_mtime) != mtime) { + const char *text_size = g_hash_table_lookup(texts, THUMB_SIZE); + if (!text_uri || strcmp(text_uri, st->uri) || + !text_mtime || atol(text_mtime) != st->mtime) { set_error(error, "mismatch or not a thumbnail"); - cairo_surface_destroy(surface); + fiv_io_image_unref(image); + return NULL; + } + if (text_size && strtoull(text_size, NULL, 10) != st->size) { + set_error(error, "file size mismatch"); + fiv_io_image_unref(image); return NULL; } - return surface; + return fiv_io_image_to_surface(image); } +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + cairo_surface_t * -fiv_thumbnail_lookup(const char *uri, gint64 mtime_msec, FivThumbnailSize size) +fiv_thumbnail_lookup(const char *uri, gint64 mtime_msec, guint64 filesize, + FivThumbnailSize size) { g_return_val_if_fail(size >= FIV_THUMBNAIL_SIZE_MIN && size <= FIV_THUMBNAIL_SIZE_MAX, NULL); @@ -647,6 +898,7 @@ fiv_thumbnail_lookup(const char *uri, gint64 mtime_msec, FivThumbnailSize size) gchar *sum = g_compute_checksum_for_string(G_CHECKSUM_MD5, uri, -1); gchar *thumbnails_dir = fiv_thumbnail_get_root(); + const Stat st = {.uri = uri, .mtime = mtime_msec / 1000, .size = filesize}; // The lookup sequence is: nominal..max, then mirroring back to ..min. cairo_surface_t *result = NULL; @@ -659,7 +911,7 @@ fiv_thumbnail_lookup(const char *uri, gint64 mtime_msec, FivThumbnailSize size) const char *name = fiv_thumbnail_sizes[use].thumbnail_spec_name; gchar *wide = g_strconcat(thumbnails_dir, G_DIR_SEPARATOR_S "wide-", name, G_DIR_SEPARATOR_S, sum, ".webp", NULL); - result = read_wide_thumbnail(wide, uri, mtime_msec / 1000, &error); + result = read_wide_thumbnail(wide, &st, &error); if (error) { g_debug("%s: %s", wide, error->message); g_clear_error(&error); @@ -675,7 +927,7 @@ fiv_thumbnail_lookup(const char *uri, gint64 mtime_msec, FivThumbnailSize size) gchar *path = g_strconcat(thumbnails_dir, G_DIR_SEPARATOR_S, name, G_DIR_SEPARATOR_S, sum, ".png", NULL); - result = read_png_thumbnail(path, uri, mtime_msec / 1000, &error); + result = read_png_thumbnail(path, &st, &error); if (error) { g_debug("%s: %s", path, error->message); g_clear_error(&error); @@ -708,7 +960,7 @@ print_error(GFile *file, GError *error) } static gchar * -identify_wide_thumbnail(GMappedFile *mf, time_t *mtime, GError **error) +identify_wide_thumbnail(GMappedFile *mf, Stat *st, GError **error) { WebPDemuxer *demux = WebPDemux(&(WebPData) { .bytes = (const uint8_t *) g_mapped_file_get_contents(mf), @@ -734,7 +986,9 @@ identify_wide_thumbnail(GMappedFile *mf, time_t *mtime, GError **error) if (!strcmp(key, THUMB_URI) && !uri) uri = g_strdup(p); if (!strcmp(key, THUMB_MTIME)) - *mtime = atol(p); + st->mtime = atol(p); + if (!strcmp(key, THUMB_SIZE)) + st->size = strtoull(p, NULL, 10); key = NULL; } else { key = p; @@ -752,16 +1006,17 @@ static void check_wide_thumbnail(GFile *thumbnail, GError **error) { // Not all errors are enough of a reason for us to delete something. - GError *tolerable = NULL; + GError *tolerable_error = NULL; const char *path = g_file_peek_path(thumbnail); - GMappedFile *mf = g_mapped_file_new(path, FALSE, &tolerable); + GMappedFile *mf = g_mapped_file_new(path, FALSE, &tolerable_error); if (!mf) { - print_error(thumbnail, tolerable); + print_error(thumbnail, tolerable_error); return; } - time_t target_mtime = 0; - gchar *target_uri = identify_wide_thumbnail(mf, &target_mtime, error); + // Note that we could enforce the presence of the size field in our spec. + Stat target_st = {.uri = NULL, .mtime = 0, .size = G_MAXUINT64}; + gchar *target_uri = identify_wide_thumbnail(mf, &target_st, error); g_mapped_file_unref(mf); if (!target_uri) return; @@ -783,26 +1038,32 @@ check_wide_thumbnail(GFile *thumbnail, GError **error) GFile *target = g_file_new_for_uri(target_uri); g_free(target_uri); GFileInfo *info = g_file_query_info(target, - G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_TIME_MODIFIED, - G_FILE_QUERY_INFO_NONE, NULL, &tolerable); + G_FILE_ATTRIBUTE_STANDARD_NAME "," + G_FILE_ATTRIBUTE_STANDARD_SIZE "," + G_FILE_ATTRIBUTE_TIME_MODIFIED, + G_FILE_QUERY_INFO_NONE, NULL, &tolerable_error); g_object_unref(target); - if (g_error_matches(tolerable, G_IO_ERROR, G_IO_ERROR_NOT_FOUND)) { - g_propagate_error(error, tolerable); + if (g_error_matches(tolerable_error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND)) { + g_propagate_error(error, tolerable_error); return; - } else if (tolerable) { - print_error(thumbnail, tolerable); + } else if (tolerable_error) { + print_error(thumbnail, tolerable_error); return; } + guint64 filesize = g_file_info_get_size(info); GDateTime *mdatetime = g_file_info_get_modification_date_time(info); g_object_unref(info); if (!mdatetime) { - set_error(&tolerable, "cannot retrieve file modification time"); - print_error(thumbnail, tolerable); + set_error(&tolerable_error, "cannot retrieve file modification time"); + print_error(thumbnail, tolerable_error); return; } - if (g_date_time_to_unix(mdatetime) != target_mtime) - set_error(error, "mtime mismatch"); + if (g_date_time_to_unix(mdatetime) != target_st.mtime) + set_error(error, "modification time mismatch"); + else if (target_st.size != G_MAXUINT64 && filesize != target_st.size) + set_error(error, "file size mismatch"); + g_date_time_unref(mdatetime); } diff --git a/fiv-thumbnail.h b/fiv-thumbnail.h index 7f3360a..0d53c01 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 <p@janouch.name> +// Copyright (c) 2021 - 2023, 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. @@ -21,7 +21,7 @@ #include <gio/gio.h> #include <glib.h> -// And this is how you avoid glib-mkenums. +// Avoid glib-mkenums. typedef enum _FivThumbnailSize { #define FIV_THUMBNAIL_SIZES(XX) \ XX(SMALL, 128, "normal") \ @@ -62,10 +62,14 @@ 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( - const char *uri, gint64 mtime_msec, FivThumbnailSize size); +cairo_surface_t *fiv_thumbnail_lookup(const char *uri, + gint64 mtime_msec, guint64 filesize, FivThumbnailSize size); /// Invalidate the wide thumbnail cache. May write to standard streams. void fiv_thumbnail_invalidate(void); 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 @@ -1,7 +1,7 @@ // // fiv-view.c: image viewing widget // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2024, 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. @@ -24,6 +24,7 @@ #include <math.h> #include <stdbool.h> +#include <epoxy/gl.h> #include <gtk/gtk.h> #ifdef GDK_WINDOWING_X11 #include <gdk/gdkx.h> @@ -63,10 +64,10 @@ struct _FivView { gchar *messages; ///< Image load information gchar *uri; ///< Path to the current image (if any) - cairo_surface_t *image; ///< The loaded image (sequence) - cairo_surface_t *page; ///< Current page within image, weak - cairo_surface_t *page_scaled; ///< Current page within image, scaled - cairo_surface_t *frame; ///< Current frame within page, weak + FivIoImage *image; ///< The loaded image (sequence) + FivIoImage *page; ///< Current page within image, weak + FivIoImage *page_scaled; ///< Current page within image, scaled + FivIoImage *frame; ///< Current frame within page, weak FivIoOrientation orientation; ///< Current page orientation bool enable_cms : 1; ///< Smooth scaling toggle bool filter : 1; ///< Smooth scaling toggle @@ -77,12 +78,16 @@ struct _FivView { double scale; ///< Scaling factor double drag_start[2]; ///< Adjustment values for drag origin - cairo_surface_t *enhance_swap; ///< Quick swap in/out - FivIoProfile screen_cms_profile; ///< Target colour profile for widget + FivIoImage *enhance_swap; ///< Quick swap in/out + FivIoProfile *screen_cms_profile; ///< Target colour profile for widget int remaining_loops; ///< Greater than zero if limited gint64 frame_time; ///< Current frame's start, µs precision gulong frame_update_connection; ///< GdkFrameClock::update + + GdkGLContext *gl_context; ///< OpenGL context + bool gl_initialized; ///< Objects have been created + GLuint gl_program; ///< Linked render program }; G_DEFINE_TYPE_EXTENDED(FivView, fiv_view, GTK_TYPE_WIDGET, 0, @@ -110,10 +115,10 @@ static FivIoOrientation view_mirror[9] = { [FivIoOrientationMirror0] = FivIoOrientation0, [FivIoOrientation180] = FivIoOrientationMirror180, [FivIoOrientationMirror180] = FivIoOrientation180, - [FivIoOrientationMirror270] = FivIoOrientation270, - [FivIoOrientation90] = FivIoOrientationMirror90, - [FivIoOrientationMirror90] = FivIoOrientation90, - [FivIoOrientation270] = FivIoOrientationMirror270, + [FivIoOrientationMirror270] = FivIoOrientation90, + [FivIoOrientation90] = FivIoOrientationMirror270, + [FivIoOrientationMirror90] = FivIoOrientation270, + [FivIoOrientation270] = FivIoOrientationMirror90, }; static FivIoOrientation view_right[9] = { @@ -161,6 +166,147 @@ enum { // Globals are, sadly, the canonical way of storing signal numbers. static guint view_signals[LAST_SIGNAL]; +// --- OpenGL ------------------------------------------------------------------ +// While GTK+ 3 technically still supports legacy desktop OpenGL 2.0[1], +// we will pick the 3.3 core profile, which is fairly old by now. +// It doesn't seem to make any sense to go below 3.2. +// +// [1] https://stackoverflow.com/a/37923507/76313 +// +// OpenGL ES +// +// Currently, we do not support OpenGL ES at all--it needs its own shaders +// (if only because of different #version statements), and also further analysis +// as to what is our minimum version requirement. While GTK+ 3 can again go +// down as low as OpenGL ES 2.0, this might be too much of a hassle to support. +// +// ES can be forced via GDK_GL=gles, if gdk_gl_context_set_required_version() +// doesn't stand in the way. +// +// Let's not forget that this is a desktop image viewer first and foremost. + +static const char * +gl_error_string(GLenum err) +{ + switch (err) { + case GL_NO_ERROR: + return "no error"; + case GL_CONTEXT_LOST: + return "context lost"; + case GL_INVALID_ENUM: + return "invalid enum"; + case GL_INVALID_VALUE: + return "invalid value"; + case GL_INVALID_OPERATION: + return "invalid operation"; + case GL_INVALID_FRAMEBUFFER_OPERATION: + return "invalid framebuffer operation"; + case GL_OUT_OF_MEMORY: + return "out of memory"; + case GL_STACK_UNDERFLOW: + return "stack underflow"; + case GL_STACK_OVERFLOW: + return "stack overflow"; + default: + return NULL; + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static const char *gl_vertex = + "#version 330\n" + "layout(location = 0) in vec4 position;\n" + "out vec2 coordinates;\n" + "void main() {\n" + "\tcoordinates = position.zw;\n" + "\tgl_Position = vec4(position.xy, 0., 1.);\n" + "}\n"; + +static const char *gl_fragment = + "#version 330\n" + "in vec2 coordinates;\n" + "layout(location = 0) out vec4 color;\n" + "uniform sampler2D picture;\n" + "uniform bool checkerboard;\n" + "\n" + "vec3 checker() {\n" + "\tvec2 xy = gl_FragCoord.xy / 20.;\n" + "\tif (checkerboard && (int(floor(xy.x) + floor(xy.y)) & 1) == 0)\n" + "\t\treturn vec3(0.98);\n" + "\telse\n" + "\t\treturn vec3(1.00);\n" + "}\n" + "\n" + "void main() {\n" + "\tvec3 c = checker();\n" + "\tvec4 t = texture(picture, coordinates);\n" + "\t// Premultiplied blending with a solid background.\n" + "\t// XXX: This is only correct for linear components.\n" + "\tcolor = vec4(c * (1. - t.a) + t.rgb, 1.);\n" + "}\n"; + +static GLuint +gl_make_shader(int type, const char *glsl) +{ + GLuint shader = glCreateShader(type); + glShaderSource(shader, 1, &glsl, NULL); + glCompileShader(shader); + + GLint status = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &status); + if (!status) { + GLint len = 0; + glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &len); + + GLchar *buffer = g_malloc0(len + 1); + glGetShaderInfoLog(shader, len, NULL, buffer); + g_warning("GL shader compilation failed: %s", buffer); + g_free(buffer); + + glDeleteShader(shader); + return 0; + } + return shader; +} + +static GLuint +gl_make_program(void) +{ + GLuint vertex = gl_make_shader(GL_VERTEX_SHADER, gl_vertex); + GLuint fragment = gl_make_shader(GL_FRAGMENT_SHADER, gl_fragment); + if (!vertex || !fragment) { + glDeleteShader(vertex); + glDeleteShader(fragment); + return 0; + } + + GLuint program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + glDeleteShader(vertex); + glDeleteShader(fragment); + + GLint status = 0; + glGetProgramiv(program, GL_LINK_STATUS, &status); + if (!status) { + GLint len = 0; + glGetProgramiv(program, GL_INFO_LOG_LENGTH, &len); + + GLchar *buffer = g_malloc0(len + 1); + glGetProgramInfoLog(program, len, NULL, buffer); + g_warning("GL program linking failed: %s", buffer); + g_free(buffer); + + glDeleteProgram(program); + return 0; + } + return program; +} + +// ----------------------------------------------------------------------------- + static void on_adjustment_value_changed( G_GNUC_UNUSED GtkAdjustment *adjustment, gpointer user_data) @@ -198,12 +344,14 @@ update_adjustments(FivView *self) if (self->hadjustment) { gtk_adjustment_configure(self->hadjustment, - gtk_adjustment_get_value(self->hadjustment), 0, dw, + gtk_adjustment_get_value(self->hadjustment), + 0, MAX(dw, alloc.width), alloc.width * 0.1, alloc.width * 0.9, alloc.width); } if (self->vadjustment) { gtk_adjustment_configure(self->vadjustment, - gtk_adjustment_get_value(self->vadjustment), 0, dh, + gtk_adjustment_get_value(self->vadjustment), + 0, MAX(dh, alloc.height), alloc.height * 0.1, alloc.height * 0.9, alloc.height); } } @@ -234,9 +382,9 @@ fiv_view_finalize(GObject *gobject) { FivView *self = FIV_VIEW(gobject); g_clear_pointer(&self->screen_cms_profile, fiv_io_profile_free); - g_clear_pointer(&self->enhance_swap, cairo_surface_destroy); - g_clear_pointer(&self->image, cairo_surface_destroy); - g_clear_pointer(&self->page_scaled, cairo_surface_destroy); + g_clear_pointer(&self->enhance_swap, fiv_io_image_unref); + g_clear_pointer(&self->image, fiv_io_image_unref); + g_clear_pointer(&self->page_scaled, fiv_io_image_unref); g_free(self->uri); g_free(self->messages); @@ -283,15 +431,13 @@ fiv_view_get_property( g_value_set_boolean(value, !!self->image); break; case PROP_CAN_ANIMATE: - g_value_set_boolean(value, self->page && - cairo_surface_get_user_data(self->page, &fiv_io_key_frame_next)); + g_value_set_boolean(value, self->page && self->page->frame_next); break; case PROP_HAS_PREVIOUS_PAGE: g_value_set_boolean(value, self->image && self->page != self->image); break; case PROP_HAS_NEXT_PAGE: - g_value_set_boolean(value, self->page && - cairo_surface_get_user_data(self->page, &fiv_io_key_page_next)); + g_value_set_boolean(value, self->page && self->page->page_next); break; case PROP_HADJUSTMENT: @@ -403,21 +549,35 @@ static void prescale_page(FivView *self) { FivIoRenderClosure *closure = NULL; - if (!self->image || !(closure = - cairo_surface_get_user_data(self->page, &fiv_io_key_render))) + if (!self->image || !(closure = self->page->render)) return; // TODO(p): Restart the animation. No vector formats currently animate. g_return_if_fail(!self->frame_update_connection); + // Optimization, taking into account the workaround in set_scale(). + if (!self->page_scaled && + (self->scale == 1 || self->scale == 0.999999999999999)) + return; + // If it fails, the previous frame pointer may become invalid. - g_clear_pointer(&self->page_scaled, cairo_surface_destroy); - self->frame = self->page_scaled = closure->render(closure, self->scale); + g_clear_pointer(&self->page_scaled, fiv_io_image_unref); + self->frame = self->page_scaled = closure->render(closure, + self->enable_cms ? fiv_io_cmm_get_default() : NULL, + self->enable_cms ? self->screen_cms_profile : NULL, self->scale); if (!self->page_scaled) self->frame = self->page; } static void +set_source_image(FivView *self, cairo_t *cr) +{ + cairo_surface_t *surface = fiv_io_image_to_surface_noref(self->frame); + cairo_set_source_surface(cr, surface, 0, 0); + cairo_surface_destroy(surface); +} + +static void fiv_view_size_allocate(GtkWidget *widget, GtkAllocation *allocation) { GTK_WIDGET_CLASS(fiv_view_parent_class)->size_allocate(widget, allocation); @@ -448,6 +608,27 @@ out: // // Note that Wayland does not have any appropriate protocol, as of writing: // https://gitlab.freedesktop.org/wayland/wayland-protocols/-/merge_requests/14 +static FivIoProfile * +monitor_cms_profile(GdkWindow *root, int num) +{ + char atom[32] = ""; + g_snprintf(atom, sizeof atom, "_ICC_PROFILE%c%d", num ? '_' : '\0', num); + + // Sadly, there is no nice GTK+/GDK mechanism to watch this for changes. + int format = 0, length = 0; + GdkAtom type = GDK_NONE; + guchar *data = NULL; + FivIoProfile *result = NULL; + if (gdk_property_get(root, gdk_atom_intern(atom, FALSE), GDK_NONE, 0, + 8 << 20 /* MiB */, FALSE, &type, &format, &length, &data)) { + if (format == 8 && length > 0) + result = fiv_io_cmm_get_profile( + fiv_io_cmm_get_default(), data, length); + g_free(data); + } + return result; +} + static void reload_screen_cms_profile(FivView *self, GdkWindow *window) { @@ -477,6 +658,7 @@ reload_screen_cms_profile(FivView *self, GdkWindow *window) GdkDisplay *display = gdk_window_get_display(window); GdkMonitor *monitor = gdk_display_get_monitor_at_window(display, window); + GdkWindow *root = gdk_screen_get_root_window(gdk_window_get_screen(window)); int num = -1; for (int i = gdk_display_get_n_monitors(display); num < 0 && i--; ) @@ -485,24 +667,14 @@ reload_screen_cms_profile(FivView *self, GdkWindow *window) if (num < 0) goto out; - char atom[32] = ""; - g_snprintf(atom, sizeof atom, "_ICC_PROFILE%c%d", num ? '_' : '\0', num); - - // Sadly, there is no nice GTK+/GDK mechanism to watch this for changes. - int format = 0, length = 0; - GdkAtom type = GDK_NONE; - guchar *data = NULL; - GdkWindow *root = gdk_screen_get_root_window(gdk_window_get_screen(window)); - if (gdk_property_get(root, gdk_atom_intern(atom, FALSE), GDK_NONE, 0, - 8 << 20 /* MiB */, FALSE, &type, &format, &length, &data)) { - if (format == 8 && length > 0) - self->screen_cms_profile = fiv_io_profile_new(data, length); - g_free(data); - } + // Cater to xiccd limitations (agalakhov/xiccd#33). + if (!(self->screen_cms_profile = monitor_cms_profile(root, num)) && num) + self->screen_cms_profile = monitor_cms_profile(root, 0); out: if (!self->screen_cms_profile) - self->screen_cms_profile = fiv_io_profile_new_sRGB(); + self->screen_cms_profile = + fiv_io_cmm_get_profile_sRGB(fiv_io_cmm_get_default()); } static void @@ -536,6 +708,9 @@ fiv_view_realize(GtkWidget *widget) GdkWindow *window = gdk_window_new(gtk_widget_get_parent_window(widget), &attributes, GDK_WA_X | GDK_WA_Y | GDK_WA_VISUAL); + GSettings *settings = g_settings_new(PROJECT_NS PROJECT_NAME); + gboolean opengl = g_settings_get_boolean(settings, "opengl"); + // Without the following call, or the rendering mode set to "recording", // RGB30 degrades to RGB24, because gdk_window_begin_paint_internal() // creates backing stores using cairo_content_t constants. @@ -545,19 +720,268 @@ fiv_view_realize(GtkWidget *widget) // Note that this disables double buffering, and sometimes causes artefacts, // see: https://gitlab.gnome.org/GNOME/gtk/-/issues/2560 // - // If GTK+'s OpenGL integration fails to deliver, we need to use the window - // directly, sidestepping the toolkit entirely. - GSettings *settings = g_settings_new(PROJECT_NS PROJECT_NAME); + // GTK+'s OpenGL integration is terrible, so we may need to use + // the X11 subwindow directly, sidestepping the toolkit entirely. if (GDK_IS_X11_WINDOW(window) && g_settings_get_boolean(settings, "native-view-window")) gdk_window_ensure_native(window); #endif // GDK_WINDOWING_X11 + g_object_unref(settings); gtk_widget_register_window(widget, window); gtk_widget_set_window(widget, window); gtk_widget_set_realized(widget, TRUE); reload_screen_cms_profile(FIV_VIEW(widget), window); + + FivView *self = FIV_VIEW(widget); + g_clear_object(&self->gl_context); + if (!opengl) + return; + + GError *error = NULL; + GdkGLContext *gl_context = gdk_window_create_gl_context(window, &error); + if (!gl_context) { + g_warning("GL: %s", error->message); + g_error_free(error); + return; + } + + gdk_gl_context_set_use_es(gl_context, FALSE); + gdk_gl_context_set_required_version(gl_context, 3, 3); + gdk_gl_context_set_debug_enabled(gl_context, TRUE); + + if (!gdk_gl_context_realize(gl_context, &error)) { + g_warning("GL: %s", error->message); + g_error_free(error); + g_object_unref(gl_context); + return; + } + + self->gl_context = gl_context; +} + +static void GLAPIENTRY +gl_on_message(G_GNUC_UNUSED GLenum source, GLenum type, G_GNUC_UNUSED GLuint id, + G_GNUC_UNUSED GLenum severity, G_GNUC_UNUSED GLsizei length, + const GLchar *message, G_GNUC_UNUSED const void *user_data) +{ + if (type == GL_DEBUG_TYPE_ERROR) + g_warning("GL: error: %s", message); + else + g_debug("GL: %s", message); +} + +static void +fiv_view_unrealize(GtkWidget *widget) +{ + FivView *self = FIV_VIEW(widget); + if (self->gl_context) { + if (self->gl_initialized) { + gdk_gl_context_make_current(self->gl_context); + glDeleteProgram(self->gl_program); + } + if (self->gl_context == gdk_gl_context_get_current()) + gdk_gl_context_clear_current(); + + g_clear_object(&self->gl_context); + } + + GTK_WIDGET_CLASS(fiv_view_parent_class)->unrealize(widget); +} + +static bool +gl_draw(FivView *self, cairo_t *cr) +{ + gdk_gl_context_make_current(self->gl_context); + + if (!self->gl_initialized) { + GLuint program = gl_make_program(); + if (!program) + return false; + + glDisable(GL_SCISSOR_TEST); + glDisable(GL_STENCIL_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glDisable(GL_BLEND); + if (epoxy_has_gl_extension("GL_ARB_debug_output")) { + glEnable(GL_DEBUG_OUTPUT); + glDebugMessageCallback(gl_on_message, NULL); + } + + self->gl_program = program; + self->gl_initialized = true; + } + + // This limit is always less than that of Cairo/pixman, + // and we'd have to figure out tiling. + GLint max = 0; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &max); + if (max < (GLint) self->frame->width || + max < (GLint) self->frame->height) { + g_warning("OpenGL max. texture size is too small"); + return false; + } + + GtkAllocation allocation; + gtk_widget_get_allocation(GTK_WIDGET(self), &allocation); + int dw = 0, dh = 0, dx = 0, dy = 0; + get_display_dimensions(self, &dw, &dh); + + int clipw = dw, cliph = dh; + double x1 = 0., y1 = 0., x2 = 1., y2 = 1.; + if (self->hadjustment) + x1 = floor(gtk_adjustment_get_value(self->hadjustment)) / dw; + if (self->vadjustment) + y1 = floor(gtk_adjustment_get_value(self->vadjustment)) / dh; + + if (dw <= allocation.width) { + dx = round((allocation.width - dw) / 2.); + } else { + x2 = x1 + (double) allocation.width / dw; + clipw = allocation.width; + } + + if (dh <= allocation.height) { + dy = round((allocation.height - dh) / 2.); + } else { + y2 = y1 + (double) allocation.height / dh; + cliph = allocation.height; + } + + enum { SRC, DEST }; + GLuint textures[2] = {}; + glGenTextures(2, textures); + + // https://stackoverflow.com/questions/25157306 0..1 + // GL_TEXTURE_RECTANGLE seems kind-of useful + glBindTexture(GL_TEXTURE_2D, textures[SRC]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + if (self->filter) { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + } else { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + } + + // GL_UNPACK_ALIGNMENT is initially 4, which is fine for these. + // Texture swizzling is OpenGL 3.3. + if (self->frame->format == CAIRO_FORMAT_ARGB32) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, + self->frame->width, self->frame->height, + 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, self->frame->data); + } else if (self->frame->format == CAIRO_FORMAT_RGB24) { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ONE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, + self->frame->width, self->frame->height, + 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, self->frame->data); + } else if (self->frame->format == CAIRO_FORMAT_RGB30) { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ONE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, + self->frame->width, self->frame->height, + 0, GL_BGRA, GL_UNSIGNED_INT_2_10_10_10_REV, self->frame->data); + } else { + g_warning("GL: unsupported bitmap format"); + } + + // GtkGLArea creates textures like this. + glBindTexture(GL_TEXTURE_2D, textures[DEST]); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, clipw, cliph, 0, GL_BGRA, + GL_UNSIGNED_BYTE, NULL); + + glViewport(0, 0, clipw, cliph); + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + + GLuint frame_buffer = 0; + glGenFramebuffers(1, &frame_buffer); + glBindFramebuffer(GL_FRAMEBUFFER, frame_buffer); + glFramebufferTexture2D( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textures[DEST], 0); + + glClearColor(0., 0., 0., 1.); + glClear(GL_COLOR_BUFFER_BIT); + + GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) + g_warning("GL framebuffer status: %u", status); + + glUseProgram(self->gl_program); + GLint position_location = glGetAttribLocation( + self->gl_program, "position"); + GLint picture_location = glGetUniformLocation( + self->gl_program, "picture"); + GLint checkerboard_location = glGetUniformLocation( + self->gl_program, "checkerboard"); + + glUniform1i(picture_location, 0); + glUniform1i(checkerboard_location, self->checkerboard); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, textures[SRC]); + + // Note that the Y axis is flipped in the table. + double vertices[][4] = { + {-1., -1., x1, y2}, + {+1., -1., x2, y2}, + {+1., +1., x2, y1}, + {-1., +1., x1, y1}, + }; + + cairo_matrix_t matrix = fiv_io_orientation_matrix(self->orientation, 1, 1); + cairo_matrix_transform_point(&matrix, &vertices[0][2], &vertices[0][3]); + cairo_matrix_transform_point(&matrix, &vertices[1][2], &vertices[1][3]); + cairo_matrix_transform_point(&matrix, &vertices[2][2], &vertices[2][3]); + cairo_matrix_transform_point(&matrix, &vertices[3][2], &vertices[3][3]); + + GLuint vertex_buffer = 0; + glGenBuffers(1, &vertex_buffer); + glBindBuffer(GL_ARRAY_BUFFER, vertex_buffer); + glBufferData(GL_ARRAY_BUFFER, sizeof vertices, vertices, GL_STATIC_DRAW); + glBindVertexArray(vao); + glVertexAttribPointer(position_location, + G_N_ELEMENTS(vertices[0]), GL_DOUBLE, GL_FALSE, sizeof vertices[0], 0); + glEnableVertexAttribArray(position_location); + glDrawArrays(GL_TRIANGLE_FAN, 0, G_N_ELEMENTS(vertices)); + glDisableVertexAttribArray(position_location); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glUseProgram(0); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + + // XXX: Native GdkWindows send this to the software fallback path. + // XXX: This only reliably alpha blends when using the software fallback, + // such as with a native window, because 7237f5d in GTK+ 3 is a regression. + // We had to resort to rendering the checkerboard pattern in the shader. + // Unfortunately, it is hard to retrieve the theme colours from CSS. + GdkWindow *window = gtk_widget_get_window(GTK_WIDGET(self)); + cairo_translate(cr, dx, dy); + gdk_cairo_draw_from_gl( + cr, window, textures[DEST], GL_TEXTURE, 1, 0, 0, clipw, cliph); + gdk_gl_context_make_current(self->gl_context); + + glDeleteBuffers(1, &vertex_buffer); + glDeleteTextures(2, textures); + glDeleteVertexArrays(1, &vao); + glDeleteFramebuffers(1, &frame_buffer); + + // TODO(p): Possibly use this clue as a hint to use Cairo rendering. + GLenum err = 0; + while ((err = glGetError()) != GL_NO_ERROR) { + const char *string = gl_error_string(err); + if (string) + g_warning("GL: error: %s", string); + else + g_warning("GL: error: %u", err); + } + + gdk_gl_context_clear_current(); + return true; } static gboolean @@ -574,8 +998,10 @@ fiv_view_draw(GtkWidget *widget, cairo_t *cr) if (!self->image || !gtk_cairo_should_draw_window(cr, gtk_widget_get_window(widget))) return TRUE; + if (self->gl_context && gl_draw(self, cr)) + return TRUE; - int dw, dh; + int dw = 0, dh = 0; get_display_dimensions(self, &dw, &dh); double x = 0; @@ -606,37 +1032,19 @@ fiv_view_draw(GtkWidget *widget, cairo_t *cr) // Then all frames are pre-scaled. if (self->page_scaled) { - cairo_set_source_surface(cr, self->frame, 0, 0); + set_source_image(self, cr); cairo_pattern_set_matrix(cairo_get_source(cr), &matrix); cairo_paint(cr); return TRUE; } - // FIXME: Recording surfaces do not work well with CAIRO_SURFACE_TYPE_XLIB, - // we always get a shitty pixmap, where transparency contains junk. - if (cairo_surface_get_type(self->frame) == CAIRO_SURFACE_TYPE_RECORDING) { - cairo_surface_t *image = - cairo_image_surface_create(CAIRO_FORMAT_ARGB32, dw, dh); - cairo_t *tcr = cairo_create(image); - cairo_scale(tcr, self->scale, self->scale); - cairo_set_source_surface(tcr, self->frame, 0, 0); - cairo_pattern_set_matrix(cairo_get_source(tcr), &matrix); - cairo_paint(tcr); - cairo_destroy(tcr); - - cairo_set_source_surface(cr, image, 0, 0); - cairo_paint(cr); - cairo_surface_destroy(image); - return TRUE; - } - // XXX: The rounding together with padding may result in up to // a pixel's worth of made-up picture data. cairo_rectangle(cr, 0, 0, dw, dh); cairo_clip(cr); cairo_scale(cr, self->scale, self->scale); - cairo_set_source_surface(cr, self->frame, 0, 0); + set_source_image(self, cr); cairo_pattern_t *pattern = cairo_get_source(cr); cairo_pattern_set_matrix(pattern, &matrix); @@ -810,15 +1218,13 @@ stop_animating(FivView *self) self->frame_time = 0; self->frame_update_connection = 0; - self->remaining_loops = 0; g_object_notify_by_pspec(G_OBJECT(self), view_properties[PROP_PLAYING]); } static gboolean advance_frame(FivView *self) { - cairo_surface_t *next = - cairo_surface_get_user_data(self->frame, &fiv_io_key_frame_next); + FivIoImage *next = self->frame->frame_next; if (next) { self->frame = next; } else { @@ -836,8 +1242,7 @@ advance_animation(FivView *self, GdkFrameClock *clock) gint64 now = gdk_frame_clock_get_frame_time(clock); while (true) { // TODO(p): See if infinite frames can actually happen, and how. - intptr_t duration = (intptr_t) cairo_surface_get_user_data( - self->frame, &fiv_io_key_frame_duration); + int64_t duration = self->frame->frame_duration; if (duration < 0) return FALSE; @@ -875,32 +1280,43 @@ start_animating(FivView *self) stop_animating(self); GdkFrameClock *clock = gtk_widget_get_frame_clock(GTK_WIDGET(self)); - if (!clock || !self->image || - !cairo_surface_get_user_data(self->page, &fiv_io_key_frame_next)) + if (!clock || !self->image || !self->page->frame_next) return; self->frame_time = gdk_frame_clock_get_frame_time(clock); self->frame_update_connection = g_signal_connect( clock, "update", G_CALLBACK(on_frame_clock_update), self); - self->remaining_loops = - (uintptr_t) cairo_surface_get_user_data(self->page, &fiv_io_key_loops); + + // Only restart looping the animation if it has stopped at the end. + if (!self->remaining_loops) { + self->remaining_loops = self->page->loops; + if (self->remaining_loops && !self->frame->frame_next) { + self->frame = self->page; + gtk_widget_queue_draw(GTK_WIDGET(self)); + } + } gdk_frame_clock_begin_updating(clock); g_object_notify_by_pspec(G_OBJECT(self), view_properties[PROP_PLAYING]); } static void -switch_page(FivView *self, cairo_surface_t *page) +switch_page(FivView *self, FivIoImage *page) { - g_clear_pointer(&self->page_scaled, cairo_surface_destroy); + g_clear_pointer(&self->page_scaled, fiv_io_image_unref); self->frame = self->page = page; + + // XXX: When self->scale_to_fit is in effect, + // this uses an old value that may no longer be appropriate, + // resulting in wasted effort. prescale_page(self); if (!self->page || - (self->orientation = (uintptr_t) cairo_surface_get_user_data( - self->page, &fiv_io_key_orientation)) == FivIoOrientationUnknown) + (self->orientation = self->page->orientation) == + FivIoOrientationUnknown) self->orientation = FivIoOrientation0; + self->remaining_loops = 0; start_animating(self); gtk_widget_queue_resize(GTK_WIDGET(self)); @@ -1027,7 +1443,7 @@ copy(FivView *self) cairo_surface_t *transformed = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h); cairo_t *cr = cairo_create(transformed); - cairo_set_source_surface(cr, self->frame, 0, 0); + set_source_image(self, cr); cairo_pattern_set_matrix(cairo_get_source(cr), &matrix); cairo_paint(cr); cairo_destroy(cr); @@ -1065,7 +1481,7 @@ on_draw_page(G_GNUC_UNUSED GtkPrintOperation *operation, cairo_t *cr = gtk_print_context_get_cairo_context(context); cairo_scale(cr, scale, scale); - cairo_set_source_surface(cr, self->frame, 0, 0); + set_source_image(self, cr); cairo_pattern_set_matrix(cairo_get_source(cr), &matrix); cairo_paint(cr); } @@ -1100,10 +1516,10 @@ print(FivView *self) } static gboolean -save_as(FivView *self, cairo_surface_t *frame) +save_as(FivView *self, FivIoImage *frame) { GtkWindow *window = get_toplevel(GTK_WIDGET(self)); - FivIoProfile target = NULL; + FivIoProfile *target = NULL; if (self->enable_cms && (target = self->screen_cms_profile)) { GtkWidget *dialog = gtk_message_dialog_new(window, GTK_DIALOG_MODAL, GTK_MESSAGE_WARNING, GTK_BUTTONS_CLOSE, "%s", @@ -1279,6 +1695,7 @@ fiv_view_class_init(FivViewClass *klass) widget_class->map = fiv_view_map; widget_class->unmap = fiv_view_unmap; widget_class->realize = fiv_view_realize; + widget_class->unrealize = fiv_view_unrealize; widget_class->draw = fiv_view_draw; widget_class->button_press_event = fiv_view_button_press_event; widget_class->scroll_event = fiv_view_scroll_event; @@ -1362,11 +1779,12 @@ fiv_view_init(FivView *self) // --- Public interface -------------------------------------------------------- -static cairo_surface_t * +static FivIoImage * open_without_swapping_in(FivView *self, const char *uri) { FivIoOpenContext ctx = { .uri = uri, + .cmm = self->enable_cms ? fiv_io_cmm_get_default() : NULL, .screen_profile = self->enable_cms ? self->screen_cms_profile : NULL, .screen_dpi = 96, // TODO(p): Try to retrieve it from the screen. .enhance = self->enhance, @@ -1374,7 +1792,7 @@ open_without_swapping_in(FivView *self, const char *uri) }; GError *error = NULL; - cairo_surface_t *surface = fiv_io_open(&ctx, &error); + FivIoImage *image = fiv_io_open(&ctx, &error); if (error) { g_ptr_array_add(ctx.warnings, g_strdup(error->message)); g_error_free(error); @@ -1387,7 +1805,7 @@ open_without_swapping_in(FivView *self, const char *uri) } g_ptr_array_free(ctx.warnings, TRUE); - return surface; + return image; } // TODO(p): Progressive picture loading, or at least async/cancellable. @@ -1395,18 +1813,18 @@ gboolean fiv_view_set_uri(FivView *self, const char *uri) { // This is extremely expensive, and only works sometimes. - g_clear_pointer(&self->enhance_swap, cairo_surface_destroy); + g_clear_pointer(&self->enhance_swap, fiv_io_image_unref); if (self->enhance) { self->enhance = FALSE; g_object_notify_by_pspec( G_OBJECT(self), view_properties[PROP_ENHANCE]); } - cairo_surface_t *surface = open_without_swapping_in(self, uri); - g_clear_pointer(&self->image, cairo_surface_destroy); + FivIoImage *image = open_without_swapping_in(self, uri); + g_clear_pointer(&self->image, fiv_io_image_unref); self->frame = self->page = NULL; - self->image = surface; + self->image = image; switch_page(self, self->image); // Otherwise, adjustment values and zoom are retained implicitly. @@ -1418,15 +1836,15 @@ fiv_view_set_uri(FivView *self, const char *uri) g_object_notify_by_pspec(G_OBJECT(self), view_properties[PROP_MESSAGES]); g_object_notify_by_pspec(G_OBJECT(self), view_properties[PROP_HAS_IMAGE]); - return surface != NULL; + return image != NULL; } static void page_step(FivView *self, int step) { - cairo_user_data_key_t *key = - step < 0 ? &fiv_io_key_page_previous : &fiv_io_key_page_next; - cairo_surface_t *page = cairo_surface_get_user_data(self->page, key); + FivIoImage *page = step < 0 + ? self->page->page_previous + : self->page->page_next; if (page) switch_page(self, page); } @@ -1435,31 +1853,35 @@ static void frame_step(FivView *self, int step) { stop_animating(self); - cairo_user_data_key_t *key = - step < 0 ? &fiv_io_key_frame_previous : &fiv_io_key_frame_next; - if (!step || !(self->frame = cairo_surface_get_user_data(self->frame, key))) + + if (step > 0) { + // Decrease the loop counter as if running on a timer. + (void) advance_frame(self); + } else if (!step || !(self->frame = self->frame->frame_previous)) { self->frame = self->page; + self->remaining_loops = 0; + } gtk_widget_queue_draw(GTK_WIDGET(self)); } static gboolean reload(FivView *self) { - cairo_surface_t *surface = open_without_swapping_in(self, self->uri); + FivIoImage *image = open_without_swapping_in(self, self->uri); g_object_notify_by_pspec(G_OBJECT(self), view_properties[PROP_MESSAGES]); - if (!surface) + if (!image) return FALSE; - g_clear_pointer(&self->image, cairo_surface_destroy); - g_clear_pointer(&self->enhance_swap, cairo_surface_destroy); - switch_page(self, (self->image = surface)); + g_clear_pointer(&self->image, fiv_io_image_unref); + g_clear_pointer(&self->enhance_swap, fiv_io_image_unref); + switch_page(self, (self->image = image)); return TRUE; } static void swap_enhanced_image(FivView *self) { - cairo_surface_t *saved = self->image; + FivIoImage *saved = self->image; self->image = self->page = self->frame = NULL; if (self->enhance_swap) { @@ -1546,9 +1968,8 @@ fiv_view_command(FivView *self, FivViewCommand command) break; case FIV_VIEW_COMMAND_PAGE_NEXT: page_step(self, +1); break; case FIV_VIEW_COMMAND_PAGE_LAST: - for (cairo_surface_t *s = self->page; - (s = cairo_surface_get_user_data(s, &fiv_io_key_page_next)); ) - self->page = s; + for (FivIoImage *I = self->page; (I = I->page_next); ) + self->page = I; switch_page(self, self->page); break; case FIV_VIEW_COMMAND_FRAME_FIRST: @@ -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 <p@janouch.name> +// Copyright (c) 2021 - 2024, 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. @@ -27,6 +27,7 @@ #include <stdarg.h> #include <stdio.h> #include <stdlib.h> +#include <string.h> #ifdef G_OS_WIN32 #include <io.h> @@ -38,6 +39,7 @@ #include "fiv-browser.h" #include "fiv-collection.h" #include "fiv-io.h" +#include "fiv-io-model.h" #include "fiv-sidebar.h" #include "fiv-thumbnail.h" #include "fiv-view.h" @@ -94,16 +96,16 @@ struct key_section { static struct key help_keys_general[] = { {"F1", "Show help"}, {"F10", "Open menu"}, - {"<Control>comma", "Preferences"}, - {"<Control>question", "Keyboard shortcuts"}, - {"q <Control>q", "Quit"}, - {"<Control>w", "Quit"}, + {"<Primary>comma", "Preferences"}, + {"<Primary>question", "Keyboard shortcuts"}, + {"q <Primary>q", "Quit"}, + {"<Primary>w", "Quit"}, {} }; static struct key help_keys_navigation[] = { - {"<Control>l", "Open location..."}, - {"<Control>n", "Open a new window"}, + {"<Primary>l", "Open location..."}, + {"<Primary>n", "Open a new window"}, {"<Alt>Left", "Go back in history"}, {"<Alt>Right", "Go forward in history"}, {} @@ -120,16 +122,20 @@ static struct key_group help_keys_browser[] = { {"General: Navigation", help_keys_navigation}, {"General: View", help_keys_view}, {"Navigation", (struct key[]) { - {"<Alt>Up", "Go to parent directory"}, {"<Alt>Home", "Go home"}, + {"<Alt>Up", "Go to parent directory"}, + {"bracketleft", "Go to previous directory in tree"}, + {"bracketright", "Go to next directory in tree"}, {"Return", "Open selected item"}, {"<Alt>Return", "Show file information"}, {} }}, {"View", (struct key[]) { + {"F7", "Toggle toolbar"}, {"F9", "Toggle navigation sidebar"}, {"F5 r <Control>r", "Reload"}, {"h <Control>h", "Toggle hiding unsupported files"}, + {"t <Control>t", "Toggle showing filenames"}, {"<Control>plus", "Larger thumbnails"}, {"<Control>minus", "Smaller thumbnails"}, {} @@ -148,7 +154,7 @@ static struct key_group help_keys_viewer[] = { {} }}, {"View", (struct key[]) { - {"F9", "Toggle toolbar"}, + {"F7", "Toggle toolbar"}, {"F5 r <Primary>r", "Reload"}, {} }}, @@ -383,12 +389,6 @@ on_about_draw(GtkWidget *widget, cairo_t *cr, gpointer user_data) GtkStyleContext *style = gtk_widget_get_style_context(widget); gtk_render_background(style, cr, 0, 0, allocation.width, allocation.height); - // The transformation matrix turns out/is applied wrongly on Quartz. - gboolean broken_backend = cairo_surface_get_type(cairo_get_target(cr)) == - CAIRO_SURFACE_TYPE_QUARTZ; - if (broken_backend) - cairo_push_group(cr); - cairo_translate(cr, (allocation.width - ABOUT_SIZE * ABOUT_SCALE) / 2, ABOUT_SIZE * ABOUT_SCALE / 4); cairo_scale(cr, ABOUT_SCALE, ABOUT_SCALE); @@ -414,11 +414,6 @@ on_about_draw(GtkWidget *widget, cairo_t *cr, gpointer user_data) cairo_restore(cr); draw_ligature(cr); - - if (broken_backend) { - cairo_pop_group_to_source(cr); - cairo_paint(cr); - } return TRUE; } @@ -459,7 +454,7 @@ show_about_dialog(GtkWidget *parent) GtkWidget *website = gtk_label_new(NULL); gtk_label_set_selectable(GTK_LABEL(website), TRUE); - const char *url = "https://git.janouch.name/p/" PROJECT_NAME; + const char *url = PROJECT_URL; gchar *link = g_strdup_printf("<a href='%s'>%s</a>", url, url); gtk_label_set_markup(GTK_LABEL(website), link); g_free(link); @@ -515,12 +510,146 @@ show_about_dialog(GtkWidget *parent) cairo_pattern_destroy(ctx.v_pattern); } +// --- Settings ---------------------------------------------------------------- + +static void +preferences_make_row( + GtkWidget *grid, int *row, GSettings *settings, GSettingsSchemaKey *key) +{ + const char *name = g_settings_schema_key_get_name(key); + const char *summary = g_settings_schema_key_get_summary(key); + const char *description = g_settings_schema_key_get_description(key); + + GtkWidget *widget = NULL; + const GVariantType *type = g_settings_schema_key_get_value_type(key); + if (g_variant_type_equal(type, G_VARIANT_TYPE_BOOLEAN)) { + widget = gtk_switch_new(); + g_settings_bind( + settings, name, widget, "active", G_SETTINGS_BIND_DEFAULT); + } else { + const gchar *type = NULL; + GVariant *value = NULL, *range = g_settings_schema_key_get_range(key); + g_variant_get(range, "(&sv)", &type, &value); + GVariantIter iter = {}; + g_variant_iter_init(&iter, value); + if (g_str_equal(type, "enum")) { + widget = gtk_combo_box_text_new(); + + GVariant *child = NULL; + while ((child = g_variant_iter_next_value(&iter))) { + const char *id = g_variant_get_string(child, NULL); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(widget), id, id); + g_variant_unref(child); + } + + g_settings_bind( + settings, name, widget, "active-id", G_SETTINGS_BIND_DEFAULT); + } + g_variant_unref(value); + g_variant_unref(range); + } + + // Ignore unimplemented value types. + if (!widget) + return; + + GtkWidget *label = gtk_label_new(summary ? summary : name); + gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_widget_set_hexpand(label, TRUE); + gtk_grid_attach(GTK_GRID(grid), label, 0, (*row), 1, 1); + gtk_widget_set_halign(widget, GTK_ALIGN_END); + gtk_grid_attach(GTK_GRID(grid), widget, 1, (*row)++, 1, 1); + + if (description) { + GtkWidget *label = gtk_label_new(description); + PangoAttrList *attr_list = pango_attr_list_new(); + pango_attr_list_insert( + attr_list, pango_attr_scale_new(PANGO_SCALE_SMALL)); + gtk_label_set_attributes( + GTK_LABEL(label), pango_attr_list_ref(attr_list)); + pango_attr_list_unref(attr_list); + + gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); + gtk_widget_set_sensitive(label, FALSE); + gtk_widget_set_size_request(label, 0, -1); + gtk_grid_attach(GTK_GRID(grid), label, 0, (*row)++, 1, 1); + } +} + +static void +show_preferences(GtkWidget *parent) +{ + GSettingsSchema *schema = NULL; + GSettings *settings = g_settings_new(PROJECT_NS PROJECT_NAME); + g_object_get(settings, "settings-schema", &schema, NULL); + + GtkWidget *dialog = gtk_widget_new(GTK_TYPE_DIALOG, + "use-header-bar", TRUE, + "title", "Preferences", + "transient-for", parent, + "destroy-with-parent", TRUE, NULL); + + GtkWidget *grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 12); + gtk_grid_set_column_spacing(GTK_GRID(grid), 24); + g_object_set(grid, "margin", 12, NULL); + gtk_box_pack_start(GTK_BOX(gtk_dialog_get_content_area(GTK_DIALOG(dialog))), + grid, TRUE, TRUE, 0); + + int row = 0; + gchar **keys = g_settings_schema_list_keys(schema); + for (gchar **p = keys; *p; p++) { +#ifndef GDK_WINDOWING_X11 + if (g_str_equal(*p, "native-view-window")) + continue; +#endif + GSettingsSchemaKey *key = g_settings_schema_get_key(schema, *p); + preferences_make_row(grid, &row, settings, key); + g_settings_schema_key_unref(key); + } + g_strfreev(keys); + g_object_unref(settings); + + gtk_window_set_default_size(GTK_WINDOW(dialog), 600, -1); + gtk_widget_show_all(dialog); + gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); +} + // --- Main -------------------------------------------------------------------- // TODO(p): See if it's possible to give separators room to shrink // by some minor amount of pixels, margin-wise. #define B make_toolbar_button #define T make_toolbar_toggle +#define R make_toolbar_radio +#define BROWSEBAR(XX) \ + XX(SIDEBAR, T("sidebar-show-symbolic", "Show sidebar")) \ + XX(S1, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(DIR_PREVIOUS, B("go-previous-symbolic", "Previous directory")) \ + XX(DIR_NEXT, B("go-next-symbolic", "Next directory")) \ + XX(S2, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(PLUS, B("zoom-in-symbolic", "Larger thumbnails")) \ + XX(MINUS, B("zoom-out-symbolic", "Smaller thumbnails")) \ + XX(S3, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(FILENAMES, T("text-symbolic", "Show filenames")) \ + XX(FILTER, T("funnel-symbolic", "Hide unsupported files")) \ + XX(S4, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + XX(SORT_DIR, B("view-sort-ascending-symbolic", "Sort ascending")) \ + XX(SORT_NAME, R("Name", "Sort by filename")) \ + XX(SORT_TIME, R("Time", "Sort by time of last modification")) \ + XX(S5, gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)) \ + /* We are YouTube. */ \ + XX(FULLSCREEN, B("view-fullscreen-symbolic", "Fullscreen")) + +enum { +#define XX(id, constructor) BROWSEBAR_ ## id, + BROWSEBAR(XX) +#undef XX + BROWSEBAR_COUNT +}; + #define TOOLBAR(XX) \ XX(BROWSE, B("view-grid-symbolic", "Browse")) \ XX(FILE_PREVIOUS, B("go-previous-symbolic", "Previous file")) \ @@ -572,10 +701,9 @@ struct { gchar *directory; ///< URI of the currently browsed directory GList *directory_back; ///< History paths as URIs going backwards GList *directory_forward; ///< History paths as URIs going forwards - GPtrArray *files; ///< "directory" contents as URIs gchar *uri; ///< Current image URI, if any - gint files_index; ///< Where "uri" is within "files" + gint files_index; ///< Where "uri" is within the model's files GtkWidget *window; GtkWidget *menu; @@ -583,11 +711,8 @@ struct { GtkWidget *browser_paned; GtkWidget *browser_sidebar; - GtkWidget *plus; - GtkWidget *minus; - GtkWidget *funnel; - GtkWidget *sort_field[FIV_IO_MODEL_SORT_COUNT]; - GtkWidget *sort_direction[2]; + GtkWidget *browser_toolbar; + GtkWidget *browsebar[BROWSEBAR_COUNT]; GtkWidget *browser_scroller; GtkWidget *browser; @@ -663,56 +788,38 @@ parent_uri(GFile *child_file) static void update_files_index(void) { + gsize files_len = 0; + FivIoModelEntry *const *files = fiv_io_model_get_files(g.model, &files_len); + g.files_index = -1; - for (guint i = 0; i < g.files->len; i++) - if (!g_strcmp0(g.uri, g_ptr_array_index(g.files, i))) + for (guint i = 0; i < files_len; i++) + if (!g_strcmp0(g.uri, files[i]->uri)) g.files_index = i; } static void -load_directory_without_reload(const char *uri) +change_directory_without_reload(const char *uri) { - gchar *uri_duplicated = g_strdup(uri); - if (g.directory_back && !strcmp(uri, g.directory_back->data)) { - // We're going back in history. - if (g.directory) { - g.directory_forward = - g_list_prepend(g.directory_forward, g.directory); - g.directory = NULL; - } + if (g.directory) { + // Note that this function can be passed g.directory directly. + if (!strcmp(uri, g.directory)) + return; - GList *link = g.directory_back; - g.directory_back = g_list_remove_link(g.directory_back, link); - g_list_free_full(link, g_free); - } else if (g.directory_forward && !strcmp(uri, g.directory_forward->data)) { - // We're going forward in history. - if (g.directory) { - g.directory_back = - g_list_prepend(g.directory_back, g.directory); - g.directory = NULL; - } - - GList *link = g.directory_forward; - g.directory_forward = g_list_remove_link(g.directory_forward, link); - g_list_free_full(link, g_free); - } else if (g.directory && strcmp(uri, g.directory)) { // We're on a new subpath. g_list_free_full(g.directory_forward, g_free); g.directory_forward = NULL; g.directory_back = g_list_prepend(g.directory_back, g.directory); - g.directory = NULL; } - g_free(g.directory); - g.directory = uri_duplicated; + g.directory = g_strdup(uri); } static void load_directory_without_switching(const char *uri) { if (uri) { - load_directory_without_reload(uri); + change_directory_without_reload(uri); GtkAdjustment *vadjustment = gtk_scrolled_window_get_vadjustment( GTK_SCROLLED_WINDOW(g.browser_scroller)); @@ -723,7 +830,7 @@ load_directory_without_switching(const char *uri) GError *error = NULL; GFile *file = g_file_new_for_uri(g.directory); if (fiv_io_model_open(g.model, file, &error)) { - // This is handled by our ::files-changed callback. + // This is handled by our ::reloaded callback. } else if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED)) { g_error_free(error); } else { @@ -738,9 +845,6 @@ load_directory(const char *uri) { load_directory_without_switching(uri); - // XXX: When something outside the filtered entries is open, the index is - // kept at -1, and browsing doesn't work. How to behave here? - // Should we add it to the pointer array as an exception? if (uri) { switch_to_browser_noselect(); @@ -750,23 +854,71 @@ load_directory(const char *uri) } static void -on_model_files_changed(FivIoModel *model, G_GNUC_UNUSED gpointer user_data) +go_back(void) +{ + if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) { + switch_to_browser_noselect(); + } else if (g.directory_back) { + if (g.directory) + g.directory_forward = + g_list_prepend(g.directory_forward, g.directory); + + const gchar *uri = g.directory = g.directory_back->data; + + GList *link = g.directory_back; + g.directory_back = g_list_remove_link(g.directory_back, link); + g_list_free(link); + + load_directory(uri); + } +} + +static void +go_forward(void) +{ + if (g.directory_forward) { + if (g.directory) + g.directory_back = + g_list_prepend(g.directory_back, g.directory); + + const gchar *uri = g.directory = g.directory_forward->data; + + GList *link = g.directory_forward; + g.directory_forward = g_list_remove_link(g.directory_forward, link); + g_list_free(link); + + load_directory(uri); + } else if (g.uri) { + switch_to_view(); + } +} + +static void +on_model_reloaded(FivIoModel *model, G_GNUC_UNUSED gpointer user_data) { g_return_if_fail(model == g.model); - gsize len = 0; - const FivIoModelEntry *files = fiv_io_model_get_files(g.model, &len); - g_ptr_array_free(g.files, TRUE); - g.files = g_ptr_array_new_full(len, g_free); - for (gsize i = 0; i < len; i++) - g_ptr_array_add(g.files, g_strdup(files[i].uri)); + gsize files_len = 0; + (void) fiv_io_model_get_files(g.model, &files_len); update_files_index(); - gtk_widget_set_sensitive( - g.toolbar[TOOLBAR_FILE_PREVIOUS], g.files->len > 1); - gtk_widget_set_sensitive( - g.toolbar[TOOLBAR_FILE_NEXT], g.files->len > 1); + gtk_widget_set_sensitive(g.toolbar[TOOLBAR_FILE_PREVIOUS], files_len > 1); + gtk_widget_set_sensitive(g.toolbar[TOOLBAR_FILE_NEXT], files_len > 1); +} + +static void +on_model_files_changed(FivIoModel *model, G_GNUC_UNUSED FivIoModelEntry *old, + G_GNUC_UNUSED FivIoModelEntry *new, G_GNUC_UNUSED gpointer user_data) +{ + on_model_reloaded(model, NULL); +} + +static void +on_sidebar_toggled(GtkToggleButton *button, G_GNUC_UNUSED gpointer user_data) +{ + gboolean active = gtk_toggle_button_get_active(button); + gtk_widget_set_visible(g.browser_sidebar, active); } static void @@ -777,21 +929,33 @@ on_filtering_toggled(GtkToggleButton *button, G_GNUC_UNUSED gpointer user_data) } static void -on_sort_field(G_GNUC_UNUSED GtkMenuItem *item, gpointer data) +on_filenames_toggled(GtkToggleButton *button, G_GNUC_UNUSED gpointer user_data) +{ + gboolean active = gtk_toggle_button_get_active(button); + g_object_set(g.browser, "show-labels", active, NULL); +} + +static void +on_sort_field(G_GNUC_UNUSED GtkToggleButton *button, gpointer data) { - int old = -1, new = (int) (intptr_t) data; + gboolean active = gtk_toggle_button_get_active(button); + if (!active) + return; + + FivIoModelSort old = FIV_IO_MODEL_SORT_COUNT; + FivIoModelSort new = (FivIoModelSort) (intptr_t) data; g_object_get(g.model, "sort-field", &old, NULL); if (old != new) g_object_set(g.model, "sort-field", new, NULL); } static void -on_sort_direction(G_GNUC_UNUSED GtkMenuItem *item, gpointer data) +on_sort_direction(G_GNUC_UNUSED GtkToggleButton *button, + G_GNUC_UNUSED gpointer data) { - gboolean old = FALSE, new = (gboolean) (intptr_t) data; + gboolean old = FALSE; g_object_get(g.model, "sort-descending", &old, NULL); - if (old != new) - g_object_set(g.model, "sort-descending", new, NULL); + g_object_set(g.model, "sort-descending", !old, NULL); } static void @@ -828,13 +992,17 @@ open_image(const char *uri) // So that load_directory() itself can be used for reloading. gchar *parent = parent_uri(file); g_object_unref(file); - if (!g.files->len /* hack to always load the directory after launch */ || - !g.directory || strcmp(parent, g.directory)) + if (!fiv_io_model_get_location(g.model) || !g.directory || + strcmp(parent, g.directory)) load_directory_without_switching(parent); else update_files_index(); g_free(parent); + // XXX: When something outside currently filtered entries is open, + // g.files_index is kept at -1, and browsing doesn't work. + // How to behave here? + switch_to_view(); } @@ -902,18 +1070,22 @@ on_open(void) static void on_previous(void) { + gsize files_len = 0; + FivIoModelEntry *const *files = fiv_io_model_get_files(g.model, &files_len); if (g.files_index >= 0) { - int previous = (g.files->len + g.files_index - 1) % g.files->len; - open_image(g_ptr_array_index(g.files, previous)); + int previous = (files_len + g.files_index - 1) % files_len; + open_image(files[previous]->uri); } } static void on_next(void) { + gsize files_len = 0; + FivIoModelEntry *const *files = fiv_io_model_get_files(g.model, &files_len); if (g.files_index >= 0) { - int next = (g.files_index + 1) % g.files->len; - open_image(g_ptr_array_index(g.files, next)); + int next = (g.files_index + 1) % files_len; + open_image(files[next]->uri); } } @@ -1089,6 +1261,40 @@ on_view_drag_data_received(G_GNUC_UNUSED GtkWidget *widget, } static void +on_notify_sidebar_visible( + GObject *object, GParamSpec *param_spec, G_GNUC_UNUSED gpointer user_data) +{ + gboolean b = FALSE; + g_object_get(object, g_param_spec_get_name(param_spec), &b, NULL); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_SIDEBAR]), b); +} + +static void +on_dir_previous(void) +{ + GFile *directory = fiv_io_model_get_previous_directory(g.model); + if (directory) { + gchar *uri = g_file_get_uri(directory); + g_object_unref(directory); + load_directory(uri); + g_free(uri); + } +} + +static void +on_dir_next(void) +{ + GFile *directory = fiv_io_model_get_next_directory(g.model); + if (directory) { + gchar *uri = g_file_get_uri(directory); + g_object_unref(directory); + load_directory(uri); + g_free(uri); + } +} + +static void on_toolbar_zoom(G_GNUC_UNUSED GtkButton *button, gpointer user_data) { FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; @@ -1105,10 +1311,22 @@ static void on_notify_thumbnail_size( GObject *object, GParamSpec *param_spec, G_GNUC_UNUSED gpointer user_data) { - FivThumbnailSize size = 0; + FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; g_object_get(object, g_param_spec_get_name(param_spec), &size, NULL); - gtk_widget_set_sensitive(g.plus, size < FIV_THUMBNAIL_SIZE_MAX); - gtk_widget_set_sensitive(g.minus, size > FIV_THUMBNAIL_SIZE_MIN); + gtk_widget_set_sensitive( + g.browsebar[BROWSEBAR_PLUS], size < FIV_THUMBNAIL_SIZE_MAX); + gtk_widget_set_sensitive( + g.browsebar[BROWSEBAR_MINUS], size > FIV_THUMBNAIL_SIZE_MIN); +} + +static void +on_notify_show_labels( + GObject *object, GParamSpec *param_spec, G_GNUC_UNUSED gpointer user_data) +{ + gboolean show_labels = 0; + g_object_get(object, g_param_spec_get_name(param_spec), &show_labels, NULL); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_FILENAMES]), show_labels); } static void @@ -1117,7 +1335,8 @@ on_notify_filtering( { gboolean b = FALSE; g_object_get(object, g_param_spec_get_name(param_spec), &b, NULL); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g.funnel), b); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_FILTER]), b); } static void @@ -1126,8 +1345,8 @@ on_notify_sort_field( { gint field = -1; g_object_get(object, g_param_spec_get_name(param_spec), &field, NULL); - gtk_check_menu_item_set_active( - GTK_CHECK_MENU_ITEM(g.sort_field[field]), TRUE); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(g.browsebar[BROWSEBAR_SORT_NAME + field]), TRUE); } static void @@ -1136,8 +1355,18 @@ on_notify_sort_descending( { gboolean b = FALSE; g_object_get(object, g_param_spec_get_name(param_spec), &b, NULL); - gtk_check_menu_item_set_active( - GTK_CHECK_MENU_ITEM(g.sort_direction[b]), TRUE); + + const char *title = b + ? "Sort ascending" + : "Sort descending"; + const char *name = b + ? "view-sort-ascending-symbolic" + : "view-sort-descending-symbolic"; + + GtkButton *button = GTK_BUTTON(g.browsebar[BROWSEBAR_SORT_DIR]); + GtkImage *image = GTK_IMAGE(gtk_button_get_image(button)); + gtk_widget_set_tooltip_text(GTK_WIDGET(button), title); + gtk_image_set_from_icon_name(image, name, GTK_ICON_SIZE_BUTTON); } static void @@ -1161,9 +1390,14 @@ on_window_state_event(G_GNUC_UNUSED GtkWidget *widget, ? "view-restore-symbolic" : "view-fullscreen-symbolic"; - GtkButton *button = GTK_BUTTON(g.toolbar[TOOLBAR_FULLSCREEN]); - GtkImage *image = GTK_IMAGE(gtk_button_get_image(button)); - gtk_image_set_from_icon_name(image, name, GTK_ICON_SIZE_BUTTON); + gtk_image_set_from_icon_name( + GTK_IMAGE(gtk_button_get_image( + GTK_BUTTON(g.toolbar[TOOLBAR_FULLSCREEN]))), + name, GTK_ICON_SIZE_BUTTON); + gtk_image_set_from_icon_name( + GTK_IMAGE(gtk_button_get_image( + GTK_BUTTON(g.browsebar[BROWSEBAR_FULLSCREEN]))), + name, GTK_ICON_SIZE_BUTTON); } static void @@ -1214,20 +1448,6 @@ show_help_shortcuts(void) } static void -show_preferences(void) -{ - char *argv[] = {"dconf-editor", PROJECT_NS PROJECT_NAME, NULL}; - GError *error = NULL; - if (!g_spawn_async( - NULL, argv, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, NULL, &error)) { - if (g_error_matches(error, G_SPAWN_ERROR, G_SPAWN_ERROR_NOENT)) - g_prefix_error_literal(&error, - "Please install dconf-editor, or use the gsettings utility.\n"); - show_error_dialog(error); - } -} - -static void toggle_sunlight(void) { GtkSettings *settings = gtk_settings_get_default(); @@ -1237,94 +1457,16 @@ toggle_sunlight(void) g_object_set(settings, property, !set, NULL); } -// Cursor keys, e.g., simply cannot be bound through accelerators -// (and GtkWidget::keynav-failed would arguably be an awful solution). -// -// GtkBindingSets can be added directly through GtkStyleContext, -// but that would still require setting up action signals on the widget class, -// which is extremely cumbersome. GtkWidget::move-focus has no return value, -// so we can't override that and abort further handling. -// -// Therefore, bind directly to keypresses. Order can be fine-tuned with -// g_signal_connect{,after}(), or overriding the handler and either tactically -// chaining up or using gtk_window_propagate_key_event(). static gboolean on_key_press(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, G_GNUC_UNUSED gpointer data) { switch (event->state & gtk_accelerator_get_default_mod_mask()) { - case GDK_MOD1_MASK | GDK_SHIFT_MASK: - if (event->keyval == GDK_KEY_D) - toggle_sunlight(); - break; case GDK_CONTROL_MASK: - case GDK_CONTROL_MASK | GDK_SHIFT_MASK: switch (event->keyval) { case GDK_KEY_h: - gtk_button_clicked(GTK_BUTTON(g.funnel)); - return TRUE; - case GDK_KEY_l: - fiv_sidebar_show_enter_location(FIV_SIDEBAR(g.browser_sidebar)); - return TRUE; - case GDK_KEY_n: - if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) - spawn_uri(g.uri); - else - spawn_uri(g.directory); - return TRUE; - case GDK_KEY_o: - on_open(); - return TRUE; - case GDK_KEY_q: - case GDK_KEY_w: - gtk_widget_destroy(g.window); - return TRUE; - - case GDK_KEY_question: - show_help_shortcuts(); - return TRUE; - case GDK_KEY_comma: - show_preferences(); - return TRUE; - } - break; - case GDK_MOD1_MASK: - switch (event->keyval) { - case GDK_KEY_Left: - if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) - switch_to_browser_noselect(); - else if (g.directory_back) - load_directory(g.directory_back->data); - return TRUE; - case GDK_KEY_Right: - if (g.directory_forward) - load_directory(g.directory_forward->data); - else if (g.uri) - switch_to_view(); - return TRUE; - } - break; - case GDK_SHIFT_MASK: - switch (event->keyval) { - case GDK_KEY_F1: - show_about_dialog(g.window); - return TRUE; - } - break; - case 0: - switch (event->keyval) { - case GDK_KEY_q: - gtk_widget_destroy(g.window); - return TRUE; - case GDK_KEY_o: - on_open(); - return TRUE; - case GDK_KEY_F1: - show_help_contents(); - return TRUE; - case GDK_KEY_F11: - case GDK_KEY_f: - toggle_fullscreen(); + // XXX: Command-H is already occupied on macOS. + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILTER])); return TRUE; } } @@ -1340,8 +1482,15 @@ on_key_press(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, gtk_accelerator_parse(accelerator, &key, &mods); g_free(accelerator); + // TODO(p): See how Unity 7 behaves, + // we might want to keep GtkApplicationWindow:show-menubar then. + gboolean shell_shows_menubar = FALSE; + (void) g_object_get(gtk_settings_get_default(), + "gtk-shell-shows-menubar", &shell_shows_menubar, NULL); + guint mask = gtk_accelerator_get_default_mod_mask(); - if (key && event->keyval == key && (event->state & mask) == mods) { + if (key && event->keyval == key && (event->state & mask) == mods && + !shell_shows_menubar) { gtk_widget_show(g.menu); // _gtk_menu_shell_set_keyboard_mode() is private. @@ -1351,6 +1500,17 @@ on_key_press(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, return FALSE; } +// Cursor keys, e.g., simply cannot be bound through accelerators +// (and GtkWidget::keynav-failed would arguably be an awful solution). +// +// GtkBindingSets can be added directly through GtkStyleContext, +// but that would still require setting up action signals on the widget class, +// which is extremely cumbersome. GtkWidget::move-focus has no return value, +// so we can't override that and abort further handling. +// +// Therefore, bind directly to keypresses. Order can be fine-tuned with +// g_signal_connect{,after}(), or overriding the handler and either tactically +// chaining up or using gtk_window_propagate_key_event(). static gboolean on_key_press_view(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, G_GNUC_UNUSED gpointer data) @@ -1358,7 +1518,7 @@ on_key_press_view(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, switch (event->state & gtk_accelerator_get_default_mod_mask()) { case 0: switch (event->keyval) { - case GDK_KEY_F9: + case GDK_KEY_F7: gtk_widget_set_visible(g.view_toolbar, !gtk_widget_is_visible(g.view_toolbar)); return TRUE; @@ -1395,6 +1555,9 @@ on_key_press_browser_paned(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, case GDK_KEY_r: load_directory(NULL); return TRUE; + case GDK_KEY_t: + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILENAMES])); + return TRUE; } break; case GDK_MOD1_MASK: @@ -1417,21 +1580,35 @@ on_key_press_browser_paned(G_GNUC_UNUSED GtkWidget *widget, GdkEventKey *event, break; case 0: switch (event->keyval) { + case GDK_KEY_F7: + gtk_widget_set_visible(g.browser_toolbar, + !gtk_widget_is_visible(g.browser_toolbar)); + return TRUE; case GDK_KEY_F9: gtk_widget_set_visible(g.browser_sidebar, !gtk_widget_is_visible(g.browser_sidebar)); return TRUE; + case GDK_KEY_bracketleft: + on_dir_previous(); + return TRUE; + case GDK_KEY_bracketright: + on_dir_next(); + return TRUE; + case GDK_KEY_Escape: fiv_browser_select(FIV_BROWSER(g.browser), NULL); return TRUE; case GDK_KEY_h: - gtk_button_clicked(GTK_BUTTON(g.funnel)); + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILTER])); return TRUE; case GDK_KEY_F5: case GDK_KEY_r: load_directory(NULL); return TRUE; + case GDK_KEY_t: + gtk_button_clicked(GTK_BUTTON(g.browsebar[BROWSEBAR_FILENAMES])); + return TRUE; } } return FALSE; @@ -1445,7 +1622,7 @@ on_button_press_view(G_GNUC_UNUSED GtkWidget *widget, GdkEventButton *event) switch (event->button) { case 4: // back (GdkWin32, GdkQuartz) case 8: // back - switch_to_browser_noselect(); + go_back(); return TRUE; case GDK_BUTTON_PRIMARY: if (event->type == GDK_2BUTTON_PRESS) { @@ -1467,15 +1644,11 @@ on_button_press_browser_paned( switch (event->button) { case 4: // back (GdkWin32, GdkQuartz) case 8: // back - if (g.directory_back) - load_directory(g.directory_back->data); + go_back(); return TRUE; case 5: // forward (GdkWin32, GdkQuartz) case 9: // forward - if (g.directory_forward) - load_directory(g.directory_forward->data); - else if (g.uri) - switch_to_view(); + go_forward(); return TRUE; default: return FALSE; @@ -1510,6 +1683,81 @@ make_toolbar_toggle(const char *symbolic, const char *tooltip) return button; } +static GtkWidget * +make_toolbar_radio(const char *label, const char *tooltip) +{ + GtkWidget *button = gtk_radio_button_new_with_label(NULL, label); + gtk_widget_set_tooltip_text(button, tooltip); + gtk_widget_set_focus_on_click(button, FALSE); + gtk_toggle_button_set_mode(GTK_TOGGLE_BUTTON(button), FALSE); + gtk_style_context_add_class( + gtk_widget_get_style_context(button), GTK_STYLE_CLASS_FLAT); + return button; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +browsebar_connect(int index, GCallback callback) +{ + g_signal_connect_swapped(g.browsebar[index], "clicked", callback, NULL); +} + +static GtkWidget * +make_browser_toolbar(void) +{ +#define XX(id, constructor) g.browsebar[BROWSEBAR_ ## id] = constructor; + BROWSEBAR(XX) +#undef XX + + // GtkStatusBar solves a problem we do not have here. + GtkWidget *browser_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_style_context_add_class( + gtk_widget_get_style_context(browser_toolbar), "fiv-toolbar"); + GtkBox *box = GTK_BOX(browser_toolbar); + + // Exploring different versions of awkward layouts. + for (int i = 0; i <= BROWSEBAR_S2; i++) + gtk_box_pack_start(box, g.browsebar[i], FALSE, FALSE, 0); + for (int i = BROWSEBAR_COUNT; --i >= BROWSEBAR_S5; ) + gtk_box_pack_end(box, g.browsebar[i], FALSE, FALSE, 0); + + GtkWidget *center = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + for (int i = BROWSEBAR_S2; ++i < BROWSEBAR_S5; ) + gtk_box_pack_start(GTK_BOX(center), g.browsebar[i], FALSE, FALSE, 0); + gtk_box_set_center_widget(box, center); + + g_signal_connect(g.browsebar[BROWSEBAR_SIDEBAR], "toggled", + G_CALLBACK(on_sidebar_toggled), NULL); + + browsebar_connect(BROWSEBAR_DIR_PREVIOUS, G_CALLBACK(on_dir_previous)); + browsebar_connect(BROWSEBAR_DIR_NEXT, G_CALLBACK(on_dir_next)); + browsebar_connect(BROWSEBAR_SORT_DIR, G_CALLBACK(on_sort_direction)); + browsebar_connect(BROWSEBAR_FULLSCREEN, G_CALLBACK(toggle_fullscreen)); + + g_signal_connect(g.browsebar[BROWSEBAR_PLUS], "clicked", + G_CALLBACK(on_toolbar_zoom), (gpointer) +1); + g_signal_connect(g.browsebar[BROWSEBAR_MINUS], "clicked", + G_CALLBACK(on_toolbar_zoom), (gpointer) -1); + + g_signal_connect(g.browsebar[BROWSEBAR_FILTER], "toggled", + G_CALLBACK(on_filtering_toggled), NULL); + g_signal_connect(g.browsebar[BROWSEBAR_FILENAMES], "toggled", + G_CALLBACK(on_filenames_toggled), NULL); + + GtkRadioButton *last = GTK_RADIO_BUTTON(g.browsebar[BROWSEBAR_SORT_NAME]); + for (int i = BROWSEBAR_SORT_NAME; i <= BROWSEBAR_SORT_TIME; i++) { + GtkRadioButton *radio = GTK_RADIO_BUTTON(g.browsebar[i]); + g_signal_connect(radio, "toggled", G_CALLBACK(on_sort_field), + (gpointer) (gintptr) i - BROWSEBAR_SORT_NAME); + gtk_radio_button_join_group(radio, last); + last = radio; + } + return browser_toolbar; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + static void on_view_actions_changed(void) { @@ -1655,7 +1903,8 @@ make_view_toolbar(void) // GtkStatusBar solves a problem we do not have here. GtkWidget *view_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_widget_set_name(view_toolbar, "toolbar"); + gtk_style_context_add_class( + gtk_widget_get_style_context(view_toolbar), "fiv-toolbar"); GtkBox *box = GTK_BOX(view_toolbar); // Exploring different versions of awkward layouts. @@ -1746,124 +1995,202 @@ make_browser_sidebar(FivIoModel *model) g_signal_connect(sidebar, "open-location", G_CALLBACK(on_open_location), NULL); - g.plus = gtk_button_new_from_icon_name("zoom-in-symbolic", - GTK_ICON_SIZE_BUTTON); - gtk_widget_set_tooltip_text(g.plus, "Larger thumbnails"); - g_signal_connect(g.plus, "clicked", - G_CALLBACK(on_toolbar_zoom), (gpointer) +1); - - g.minus = gtk_button_new_from_icon_name("zoom-out-symbolic", - GTK_ICON_SIZE_BUTTON); - gtk_widget_set_tooltip_text(g.minus, "Smaller thumbnails"); - g_signal_connect(g.minus, "clicked", - G_CALLBACK(on_toolbar_zoom), (gpointer) -1); + g_signal_connect(sidebar, "notify::visible", + G_CALLBACK(on_notify_sidebar_visible), NULL); - GtkWidget *zoom_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_style_context_add_class( - gtk_widget_get_style_context(zoom_group), GTK_STYLE_CLASS_LINKED); - gtk_box_pack_start(GTK_BOX(zoom_group), g.plus, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(zoom_group), g.minus, FALSE, FALSE, 0); - - g.funnel = gtk_toggle_button_new(); - gtk_container_add(GTK_CONTAINER(g.funnel), - gtk_image_new_from_icon_name("funnel-symbolic", GTK_ICON_SIZE_BUTTON)); - gtk_widget_set_tooltip_text(g.funnel, "Hide unsupported files"); - g_signal_connect(g.funnel, "toggled", - G_CALLBACK(on_filtering_toggled), NULL); - - GtkWidget *menu = gtk_menu_new(); - g.sort_field[0] = gtk_radio_menu_item_new_with_mnemonic(NULL, "By _Name"); - g.sort_field[1] = gtk_radio_menu_item_new_with_mnemonic( - gtk_radio_menu_item_get_group(GTK_RADIO_MENU_ITEM(g.sort_field[0])), - "By _Modification Time"); - for (int i = FIV_IO_MODEL_SORT_MIN; i <= FIV_IO_MODEL_SORT_MAX; i++) { - g_signal_connect(g.sort_field[i], "activate", - G_CALLBACK(on_sort_field), (void *) (intptr_t) i); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), g.sort_field[i]); - } - - g.sort_direction[0] = - gtk_radio_menu_item_new_with_mnemonic(NULL, "_Ascending"); - g.sort_direction[1] = gtk_radio_menu_item_new_with_mnemonic( - gtk_radio_menu_item_get_group(GTK_RADIO_MENU_ITEM(g.sort_direction[0])), - "_Descending"); - g_signal_connect(g.sort_direction[0], "activate", - G_CALLBACK(on_sort_direction), (void *) 0); - g_signal_connect(g.sort_direction[1], "activate", - G_CALLBACK(on_sort_direction), (void *) 1); - - gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), g.sort_direction[0]); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), g.sort_direction[1]); - gtk_widget_show_all(menu); - - GtkWidget *sort = gtk_menu_button_new(); - gtk_widget_set_tooltip_text(sort, "Sort order"); - gtk_button_set_image(GTK_BUTTON(sort), - gtk_image_new_from_icon_name( - "view-sort-ascending-symbolic", GTK_ICON_SIZE_BUTTON)); - gtk_menu_button_set_popup(GTK_MENU_BUTTON(sort), menu); - - GtkWidget *model_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_style_context_add_class( - gtk_widget_get_style_context(model_group), GTK_STYLE_CLASS_LINKED); - gtk_box_pack_start(GTK_BOX(model_group), g.funnel, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(model_group), sort, FALSE, FALSE, 0); - - GtkBox *toolbar = fiv_sidebar_get_toolbar(FIV_SIDEBAR(sidebar)); - gtk_box_pack_start(toolbar, zoom_group, FALSE, FALSE, 0); - gtk_box_pack_start(toolbar, model_group, FALSE, FALSE, 0); - gtk_widget_set_halign(GTK_WIDGET(toolbar), GTK_ALIGN_CENTER); + g_object_notify(G_OBJECT(sidebar), "visible"); g_signal_connect(g.browser, "notify::thumbnail-size", G_CALLBACK(on_notify_thumbnail_size), NULL); + g_signal_connect(g.browser, "notify::show-labels", + G_CALLBACK(on_notify_show_labels), NULL); g_signal_connect(model, "notify::filtering", G_CALLBACK(on_notify_filtering), NULL); g_signal_connect(model, "notify::sort-field", G_CALLBACK(on_notify_sort_field), NULL); g_signal_connect(model, "notify::sort-descending", G_CALLBACK(on_notify_sort_descending), NULL); + on_toolbar_zoom(NULL, (gpointer) 0); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(g.funnel), TRUE); - // TODO(p): Invoke sort configuration notifications explicitly. + + g_object_notify(G_OBJECT(g.model), "filtering"); + g_object_notify(G_OBJECT(g.model), "sort-field"); + g_object_notify(G_OBJECT(g.model), "sort-descending"); return sidebar; } +// --- Actions ----------------------------------------------------------------- + +#define ACTION(name) static void on_action_ ## name(void) + +ACTION(new_window) { + if (gtk_stack_get_visible_child(GTK_STACK(g.stack)) == g.view_box) + spawn_uri(g.uri); + else + spawn_uri(g.directory); +} + +ACTION(quit) { + gtk_widget_destroy(g.window); +} + +ACTION(location) { + fiv_sidebar_show_enter_location(FIV_SIDEBAR(g.browser_sidebar)); +} + +ACTION(preferences) { + show_preferences(g.window); +} + +ACTION(about) { + show_about_dialog(g.window); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +typedef struct { + const char *name; ///< Unprefixed action name + GCallback callback; ///< Simple callback + const char **accels; ///< NULL-terminated accelerator list +} ActionEntry; + +static ActionEntry g_actions[] = { + {"preferences", on_action_preferences, + (const char *[]) {"<Primary>comma", NULL}}, + {"new-window", on_action_new_window, + (const char *[]) {"<Primary>n", NULL}}, + {"open", on_open, + (const char *[]) {"<Primary>o", "o", NULL}}, + {"quit", on_action_quit, + (const char *[]) {"<Primary>q", "<Primary>w", "q", NULL}}, + {"toggle-fullscreen", toggle_fullscreen, + (const char *[]) {"F11", "f", NULL}}, + {"toggle-sunlight", toggle_sunlight, + (const char *[]) {"<Alt><Shift>d", NULL}}, + {"go-back", go_back, + (const char *[]) {"<Alt>Left", "BackSpace", NULL}}, + {"go-forward", go_forward, + (const char *[]) {"<Alt>Right", NULL}}, + {"go-location", on_action_location, + (const char *[]) {"<Primary>l", NULL}}, + {"help", show_help_contents, + (const char *[]) {"F1", NULL}}, + {"shortcuts", show_help_shortcuts, + // Similar to win.show-help-overlay in gtkapplication.c. + (const char *[]) {"<Primary>question", "<Primary>F1", NULL}}, + {"about", on_action_about, + (const char *[]) {"<Shift>F1", NULL}}, + {} +}; + +static void +dispatch_action(G_GNUC_UNUSED GSimpleAction *action, + G_GNUC_UNUSED GVariant *parameter, gpointer user_data) +{ + GCallback callback = user_data; + callback(); +} + +static void +set_up_action(GtkApplication *app, const ActionEntry *a) +{ + GSimpleAction *action = g_simple_action_new(a->name, NULL); + g_signal_connect(action, "activate", + G_CALLBACK(dispatch_action), a->callback); + g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(action)); + g_object_unref(action); + + gchar *full_name = g_strdup_printf("app.%s", a->name); + gtk_application_set_accels_for_action(app, full_name, a->accels); + g_free(full_name); +} + +// --- Menu -------------------------------------------------------------------- + +typedef struct { + const char *label; ///< Label, with a mnemonic + const char *action; ///< Prefixed action name + gboolean macos; ///< Show in the macOS global menu? +} MenuItem; + +typedef struct { + const char *label; ///< Label, with a mnemonic + const MenuItem *items; ///< ""-sectioned menu items +} MenuRoot; + +// We're single-instance, skip the "win" namespace for simplicity. +static MenuRoot g_menu[] = { + {"_File", (MenuItem[]) { + {"_New Window", "app.new-window", TRUE}, + {"_Open...", "app.open", TRUE}, + {"", NULL, TRUE}, + {"_Quit", "app.quit", FALSE}, + {} + }}, + {"_Go", (MenuItem[]) { + {"_Back", "app.go-back", TRUE}, + {"_Forward", "app.go-forward", TRUE}, + {"", NULL, TRUE}, + {"_Location...", "app.go-location", TRUE}, + {} + }}, + {"_Help", (MenuItem[]) { + {"_Contents", "app.help", TRUE}, + {"_Keyboard Shortcuts", "app.shortcuts", TRUE}, + {"_About", "app.about", FALSE}, + {} + }}, + {} +}; + +static GMenuModel * +make_submenu(const MenuItem *items) +{ + GMenu *menu = g_menu_new(); + while (items->label) { + GMenu *section = g_menu_new(); + for (; items->label; items++) { + // Empty strings are interpreted as separators. + if (!*items->label) { + items++; + break; + } + + GMenuItem *subitem = g_menu_item_new(items->label, items->action); + if (!items->macos) { + g_menu_item_set_attribute( + subitem, "hidden-when", "s", "macos-menubar"); + } + + g_menu_append_item(section, subitem); + g_object_unref(subitem); + } + g_menu_append_section(menu, NULL, G_MENU_MODEL(section)); + g_object_unref(section); + } + return G_MENU_MODEL(menu); +} + +static GMenuModel * +make_menu_model(void) +{ + GMenu *menu = g_menu_new(); + for (const MenuRoot *root = g_menu; root->label; root++) { + GMenuModel *submenu = make_submenu(root->items); + g_menu_append_submenu(menu, root->label, submenu); + g_object_unref(submenu); + } + return G_MENU_MODEL(menu); +} + static GtkWidget * -make_menu_bar(void) -{ - g.menu = gtk_menu_bar_new(); - - GtkWidget *item_quit = gtk_menu_item_new_with_mnemonic("_Quit"); - g_signal_connect_swapped(item_quit, "activate", - G_CALLBACK(gtk_widget_destroy), g.window); - - GtkWidget *menu_file = gtk_menu_new(); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_file), item_quit); - GtkWidget *item_file = gtk_menu_item_new_with_mnemonic("_File"); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_file), menu_file); - gtk_menu_shell_append(GTK_MENU_SHELL(g.menu), item_file); - - GtkWidget *item_contents = gtk_menu_item_new_with_mnemonic("_Contents"); - g_signal_connect_swapped(item_contents, "activate", - G_CALLBACK(show_help_contents), NULL); - GtkWidget *item_shortcuts = - gtk_menu_item_new_with_mnemonic("_Keyboard Shortcuts"); - g_signal_connect_swapped(item_shortcuts, "activate", - G_CALLBACK(show_help_shortcuts), NULL); - GtkWidget *item_about = gtk_menu_item_new_with_mnemonic("_About"); - g_signal_connect_swapped(item_about, "activate", - G_CALLBACK(show_about_dialog), g.window); - - GtkWidget *menu_help = gtk_menu_new(); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_help), item_contents); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_help), item_shortcuts); - gtk_menu_shell_append(GTK_MENU_SHELL(menu_help), item_about); - GtkWidget *item_help = gtk_menu_item_new_with_mnemonic("_Help"); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_help), menu_help); - gtk_menu_shell_append(GTK_MENU_SHELL(g.menu), item_help); +make_menu_bar(GMenuModel *model) +{ + g.menu = gtk_menu_bar_new_from_model(model); // Don't let it take up space by default. Firefox sets a precedent here. + // (gtk_application_window_set_show_menubar() doesn't seem viable for use + // for this purpose.) gtk_widget_show_all(g.menu); gtk_widget_set_no_show_all(g.menu, TRUE); gtk_widget_hide(g.menu); @@ -1871,6 +2198,8 @@ make_menu_bar(void) return g.menu; } +// --- Application ------------------------------------------------------------- + // This is incredibly broken https://stackoverflow.com/a/51054396/76313 // thus resolving the problem using overlaps. // We're trying to be universal for light and dark themes both. It's hard. @@ -1878,12 +2207,14 @@ static const char stylesheet[] = "@define-color fiv-tile @content_view_bg; \ @define-color fiv-semiselected \ mix(@theme_selected_bg_color, @content_view_bg, 0.5); \ fiv-view, fiv-browser { background: @content_view_bg; } \ - placessidebar.fiv .toolbar { padding: 2px 6px; } \ placessidebar.fiv box > separator { margin: 4px 0; } \ - #toolbar button { padding-left: 0; padding-right: 0; } \ - #toolbar > button:first-child { padding-left: 4px; } \ - #toolbar > button:last-child { padding-right: 4px; } \ - #toolbar separator { \ + placessidebar.fiv row { min-height: 2em; } \ + .fiv-toolbar button { padding-left: 0; padding-right: 0; } \ + .fiv-toolbar button.text-button { \ + padding-left: 4px; padding-right: 4px; } \ + .fiv-toolbar > button:first-child { padding-left: 4px; } \ + .fiv-toolbar > button:last-child { padding-right: 4px; } \ + .fiv-toolbar separator { \ background: mix(@insensitive_fg_color, \ @insensitive_bg_color, 0.4); margin: 6px 8px; \ } \ @@ -1902,6 +2233,12 @@ static const char stylesheet[] = "@define-color fiv-tile @content_view_bg; \ background-size: 40px 40px; \ background-position: 0 0, 0 20px, 20px -20px, -20px 0px; \ } \ + fiv-browser.item.label, fiv-browser.item.symbolic.label { \ + color: @theme_fg_color; \ + } \ + fiv-browser.item.label:backdrop:not(:selected) { \ + color: @theme_unfocused_fg_color; \ + } \ fiv-browser.item:selected { \ color: @theme_selected_bg_color; \ border-color: @theme_selected_bg_color; \ @@ -1935,113 +2272,19 @@ 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) -{ - if (!uris) - exit_fatal("No path given"); - if (uris[1]) - exit_fatal("Only one thumbnail at a time may be produced"); - - FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; - if (size_arg) { - for (size = 0; size < FIV_THUMBNAIL_SIZE_COUNT; size++) { - if (!strcmp( - fiv_thumbnail_sizes[size].thumbnail_spec_name, size_arg)) - break; - } - if (size >= FIV_THUMBNAIL_SIZE_COUNT) - exit_fatal("unknown thumbnail size: %s", size_arg); - } - -#ifdef G_OS_WIN32 - _setmode(fileno(stdout), _O_BINARY); -#endif - - GError *error = NULL; - GFile *file = g_file_new_for_uri(uris[0]); - cairo_surface_t *surface = NULL; - if (extract && (surface = fiv_thumbnail_extract(file, size, &error))) - fiv_io_serialize_to_stdout(surface, FIV_IO_SERIALIZE_LOW_QUALITY); - else if (size_arg && - (g_clear_error(&error), - (surface = fiv_thumbnail_produce(file, size, &error)))) - fiv_io_serialize_to_stdout(surface, 0); - else - g_assert(error != NULL); - - g_object_unref(file); - if (error) - exit_fatal("%s", error->message); - - cairo_surface_destroy(surface); -} - -int -main(int argc, char *argv[]) +on_app_startup(GApplication *app, G_GNUC_UNUSED gpointer user_data) { - gboolean show_version = FALSE, show_supported_media_types = FALSE, - invalidate_cache = FALSE, browse = FALSE, extract_thumbnail = FALSE; - gchar **args = NULL, *thumbnail_size = NULL; - const GOptionEntry options[] = { - {G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_FILENAME_ARRAY, &args, - NULL, "[PATH | URI]..."}, - {"list-supported-media-types", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &show_supported_media_types, - "Output supported media types and exit", NULL}, - {"browse", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &browse, - "Start in filesystem browsing mode", NULL}, - {"extract-thumbnail", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &extract_thumbnail, - "Output any embedded thumbnail (superseding --thumbnail)", NULL}, - {"thumbnail", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_STRING, &thumbnail_size, - "Generate thumbnails, up to SIZE, and output that size", "SIZE"}, - {"invalidate-cache", 0, G_OPTION_FLAG_IN_MAIN, - G_OPTION_ARG_NONE, &invalidate_cache, - "Invalidate the wide thumbnail cache", NULL}, - {"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, " - Image browser and viewer", options, NULL, &error); - if (show_version) { - const char *version = PROJECT_VERSION; - printf("%s %s\n", PROJECT_NAME, &version[*version == 'v']); - return 0; - } - if (show_supported_media_types) { - for (char **types = fiv_io_all_supported_media_types(); *types; ) - g_print("%s\n", *types++); - return 0; - } - if (invalidate_cache) { - fiv_thumbnail_invalidate(); - return 0; - } - if (!initialized) - exit_fatal("%s", error->message); - - // Normalize all arguments to URIs. - for (gsize i = 0; args && args[i]; i++) { - GFile *resolved = g_file_new_for_commandline_arg(args[i]); - g_free(args[i]); - args[i] = g_file_get_uri(resolved); - g_object_unref(resolved); - } - if (extract_thumbnail || thumbnail_size) { - output_thumbnail(args, extract_thumbnail, thumbnail_size); - return 0; - } + // We can't prevent GApplication from adding --gapplication-service. + if (g_application_get_flags(app) & G_APPLICATION_IS_SERVICE) + exit(EXIT_FAILURE); // It doesn't make much sense to have command line arguments able to // resolve to the VFS they may end up being contained within. fiv_collection_register(); g.model = g_object_new(FIV_TYPE_IO_MODEL, NULL); + g_signal_connect(g.model, "reloaded", + G_CALLBACK(on_model_reloaded), NULL); g_signal_connect(g.model, "files-changed", G_CALLBACK(on_model_files_changed), NULL); @@ -2068,7 +2311,7 @@ main(int argc, char *argv[]) G_CALLBACK(on_view_drag_data_received), NULL); gtk_container_add(GTK_CONTAINER(view_scroller), g.view); - // We need to hide it together with the separator. + // We need to hide it together with its separator. g.view_toolbar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_pack_start(GTK_BOX(g.view_toolbar), make_view_toolbar(), FALSE, FALSE, 0); @@ -2108,10 +2351,23 @@ main(int argc, char *argv[]) G_CALLBACK(on_item_activated), NULL); gtk_container_add(GTK_CONTAINER(g.browser_scroller), g.browser); + // We need to hide it together with its separator. + g.browser_toolbar = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_pack_start(GTK_BOX(g.browser_toolbar), + make_browser_toolbar(), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(g.browser_toolbar), + gtk_separator_new(GTK_ORIENTATION_VERTICAL), FALSE, FALSE, 0); + + GtkWidget *browser_right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_pack_start(GTK_BOX(browser_right), + g.browser_toolbar, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(browser_right), + g.browser_scroller, TRUE, TRUE, 0); + g.browser_sidebar = make_browser_sidebar(g.model); g.browser_paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); gtk_paned_add1(GTK_PANED(g.browser_paned), g.browser_sidebar); - gtk_paned_add2(GTK_PANED(g.browser_paned), g.browser_scroller); + gtk_paned_add2(GTK_PANED(g.browser_paned), browser_right); g_signal_connect(g.browser_paned, "key-press-event", G_CALLBACK(on_key_press_browser_paned), NULL); g_signal_connect(g.browser_paned, "button-press-event", @@ -2123,18 +2379,35 @@ main(int argc, char *argv[]) gtk_container_add(GTK_CONTAINER(g.stack), g.view_box); gtk_container_add(GTK_CONTAINER(g.stack), g.browser_paned); - g.window = gtk_window_new(GTK_WINDOW_TOPLEVEL); - g_signal_connect(g.window, "destroy", - G_CALLBACK(gtk_main_quit), NULL); + g.window = gtk_application_window_new(GTK_APPLICATION(app)); + g_signal_connect_swapped(g.window, "destroy", + G_CALLBACK(g_application_quit), app); g_signal_connect(g.window, "key-press-event", G_CALLBACK(on_key_press), NULL); g_signal_connect(g.window, "window-state-event", G_CALLBACK(on_window_state_event), NULL); + for (const ActionEntry *a = g_actions; a->name; a++) + set_up_action(GTK_APPLICATION(app), a); + + // GtkApplicationWindow overrides GtkContainer/GtkWidget virtual methods + // so that it has the menu bar as an extra child (if it so decides). + // However, we currently want this menu bar to only show on a key press, + // and to hide as soon as it's no longer being used. + // Messing with the window's internal state seems at best quirky, + // so we'll manage the menu entirely by ourselves. + gtk_application_window_set_show_menubar( + GTK_APPLICATION_WINDOW(g.window), FALSE); + + GMenuModel *menu = make_menu_model(); + gtk_application_set_menubar(GTK_APPLICATION(app), menu); + // The default "app menu" is good, in particular for macOS, so keep it. + GtkWidget *menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_container_add(GTK_CONTAINER(menu_box), make_menu_bar()); + gtk_container_add(GTK_CONTAINER(menu_box), make_menu_bar(menu)); gtk_container_add(GTK_CONTAINER(menu_box), g.stack); gtk_container_add(GTK_CONTAINER(g.window), menu_box); + g_object_unref(menu); GSettings *settings = g_settings_new(PROJECT_NS PROJECT_NAME); if (g_settings_get_boolean(settings, "dark-theme")) @@ -2145,6 +2418,8 @@ main(int argc, char *argv[]) gtk_widget_show_all(menu_box); gtk_widget_set_visible(g.browser_sidebar, g_settings_get_boolean(settings, "show-browser-sidebar")); + gtk_widget_set_visible(g.browser_toolbar, + g_settings_get_boolean(settings, "show-browser-toolbar")); gtk_widget_set_visible(g.view_toolbar, g_settings_get_boolean(settings, "show-view-toolbar")); @@ -2174,24 +2449,34 @@ main(int argc, char *argv[]) // XXX: The widget wants to read the display's profile. The realize is ugly. gtk_widget_realize(g.view); +} + +static struct { + gboolean browse, collection, extract_thumbnail; + gchar **args, *thumbnail_size, *thumbnail_size_search; +} o; +static void +on_app_activate( + G_GNUC_UNUSED GApplication *app, G_GNUC_UNUSED gpointer user_data) +{ // XXX: We follow the behaviour of Firefox and Eye of GNOME, which both // interpret multiple command line arguments differently, as a collection. - // However, single-element collections are unrepresentable this way. - // Should we allow multiple targets only in a special new mode? - g.files = g_ptr_array_new_full(0, g_free); - if (args) { - const gchar *target = *args; - if (args[1]) { - fiv_collection_reload(args); + // However, single-element collections are unrepresentable this way, + // so we have a switch to enforce it. + g.files_index = -1; + if (o.args) { + const gchar *target = *o.args; + if (o.args[1] || o.collection) { + fiv_collection_reload(o.args); target = FIV_COLLECTION_SCHEME ":/"; } GFile *file = g_file_new_for_uri(target); - open_any_file(file, browse); + open_any_file(file, o.browse); g_object_unref(file); - g_strfreev(args); } + if (!g.directory) { GFile *file = g_file_new_for_path("."); open_any_file(file, FALSE); @@ -2199,6 +2484,182 @@ main(int argc, char *argv[]) } gtk_widget_show(g.window); - gtk_main(); - return 0; +} + +// --- Plumbing ---------------------------------------------------------------- + +static FivThumbnailSize +output_thumbnail_prologue(gchar **uris, const char *size_arg) +{ + if (!uris) + exit_fatal("No path given"); + if (uris[1]) + exit_fatal("Only one thumbnail at a time may be produced"); + + FivThumbnailSize size = FIV_THUMBNAIL_SIZE_COUNT; + if (size_arg) { + for (size = 0; size < FIV_THUMBNAIL_SIZE_COUNT; size++) { + if (!strcmp( + fiv_thumbnail_sizes[size].thumbnail_spec_name, size_arg)) + break; + } + if (size >= FIV_THUMBNAIL_SIZE_COUNT) + exit_fatal("unknown thumbnail size: %s", 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]); + cairo_surface_t *surface = NULL; + if (extract && (surface = fiv_thumbnail_extract(file, size, &error))) + fiv_io_serialize_to_stdout(surface, FIV_IO_SERIALIZE_LOW_QUALITY); + else if (size_arg && + (g_clear_error(&error), + (surface = fiv_thumbnail_produce(file, size, &error)))) + fiv_io_serialize_to_stdout(surface, 0); + else + g_assert(error != NULL); + + g_object_unref(file); + if (error) + exit_fatal("%s", error->message); + + cairo_surface_destroy(surface); +} + +static gint +on_app_handle_local_options(G_GNUC_UNUSED GApplication *app, + GVariantDict *options, G_GNUC_UNUSED gpointer user_data) +{ + if (g_variant_dict_contains(options, "version")) { + const char *version = PROJECT_VERSION; + printf("%s %s\n", PROJECT_NAME, &version[*version == 'v']); + return 0; + } + if (g_variant_dict_contains(options, "list-supported-media-types")) { + char **types = fiv_io_all_supported_media_types(); + for (char **p = types; *p; p++) + g_print("%s\n", *p); + g_strfreev(types); + return 0; + } + if (g_variant_dict_contains(options, "invalidate-cache")) { + fiv_thumbnail_invalidate(); + return 0; + } + + // Normalize all arguments to URIs, and run thumbnailing modes first. + for (gsize i = 0; o.args && o.args[i]; i++) { + GFile *resolved = g_file_new_for_commandline_arg(o.args[i]); + g_free(o.args[i]); + o.args[i] = g_file_get_uri(resolved); + g_object_unref(resolved); + } + + // These come from an option group that doesn't get copied to "options". + if (o.thumbnail_size_search) { + output_thumbnail_for_search(o.args, o.thumbnail_size_search); + return 0; + } + if (o.extract_thumbnail || o.thumbnail_size) { + output_thumbnail(o.args, o.extract_thumbnail, o.thumbnail_size); + return 0; + } + return -1; +} + +int +main(int argc, char *argv[]) +{ + const GOptionEntry options[] = { + {G_OPTION_REMAINING, 0, 0, + G_OPTION_ARG_FILENAME_ARRAY, &o.args, + NULL, "[PATH | URI]..."}, + {"browse", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, &o.browse, + "Start in filesystem browsing mode", NULL}, + {"collection", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, &o.collection, + "Always put arguments in a collection (implies --browse)", NULL}, + {"invalidate-cache", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, NULL, + "Invalidate the wide thumbnail cache", NULL}, + {"list-supported-media-types", 0, G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, NULL, + "Output supported media types and exit", NULL}, + {"version", 'V', G_OPTION_FLAG_IN_MAIN, + G_OPTION_ARG_NONE, NULL, + "Output version information and exit", NULL}, + {}, + }; + const GOptionEntry options_internal[] = { + {"extract-thumbnail", 0, 0, + G_OPTION_ARG_NONE, &o.extract_thumbnail, + "Output any embedded thumbnail (superseding --thumbnail)", NULL}, + {"thumbnail", 0, 0, + G_OPTION_ARG_STRING, &o.thumbnail_size, + "Generate thumbnails, up to SIZE, and output that size", "SIZE"}, + {"thumbnail-for-search", 0, 0, + G_OPTION_ARG_STRING, &o.thumbnail_size_search, + "Output an image file suitable for searching by content", "SIZE"}, + {}, + }; + + // We never get the ::open signal, thanks to G_OPTION_ARG_FILENAME_ARRAY. + GtkApplication *app = gtk_application_new(NULL, G_APPLICATION_NON_UNIQUE); + g_application_set_option_context_parameter_string( + G_APPLICATION(app), " - Image browser and viewer"); + g_application_add_main_option_entries(G_APPLICATION(app), options); + + GOptionGroup *internals = g_option_group_new( + "internal", "Internal Options:", "Show internal options", NULL, NULL); + g_option_group_add_entries(internals, options_internal); + g_application_add_option_group(G_APPLICATION(app), internals); + + g_signal_connect(app, "handle-local-options", + G_CALLBACK(on_app_handle_local_options), NULL); + g_signal_connect(app, "startup", + G_CALLBACK(on_app_startup), NULL); + g_signal_connect(app, "activate", + G_CALLBACK(on_app_activate), NULL); + + int status = g_application_run(G_APPLICATION(app), argc, argv); + g_object_unref(app); + g_strfreev(o.args); + return status; } diff --git a/fiv.gschema.xml b/fiv.gschema.xml index a0b9aab..f869d2f 100644 --- a/fiv.gschema.xml +++ b/fiv.gschema.xml @@ -17,6 +17,13 @@ double buffering. </description> </key> + <key name='opengl' type='b'> + <default>false</default> + <summary>Use experimental OpenGL rendering</summary> + <description> + OpenGL within GTK+ is highly problematic--you don't want this. + </description> + </key> <key name='dark-theme' type='b'> <default>false</default> <summary>Use a dark theme variant on start-up</summary> @@ -25,6 +32,10 @@ <default>true</default> <summary>Show the browser's sidebar</summary> </key> + <key name='show-browser-toolbar' type='b'> + <default>true</default> + <summary>Show a toolbar in the browser view</summary> + </key> <key name='show-view-toolbar' type='b'> <default>true</default> <summary>Show a toolbar in the image view</summary> diff --git a/fiv.wxs.in b/fiv.wxs.in new file mode 100644 index 0000000..f44611b --- /dev/null +++ b/fiv.wxs.in @@ -0,0 +1,71 @@ +<?xml version='1.0' encoding='utf-8'?> +<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'> + <?define FullName = "@ProjectName@ @ProjectVersion@" ?> + <?if $(sys.BUILDARCH) = x64 ?> + <?define ProgramFilesFolder = "ProgramFiles64Folder" ?> + <?else?> + <?define ProgramFilesFolder = "ProgramFilesFolder" ?> + <?endif?> + + <Product Id='*' + Name='$(var.FullName)' + UpgradeCode='a3e64e2d-4310-4c5f-8562-bb0e0b3e0a53' + Language='1033' + Codepage='1252' + Version='@ProjectVersion@' + Manufacturer='Premysl Eric Janouch'> + + <Package Id='*' + Keywords='Installer,Image,Viewer' + Description='$(var.FullName) Installer' + Manufacturer='Premysl Eric Janouch' + InstallerVersion='200' + Compressed='yes' + Languages='1033' + SummaryCodepage='1252' /> + + <Media Id='1' Cabinet='data.cab' EmbedCab='yes' /> + <Icon Id='fiv.ico' SourceFile='fiv.ico' /> + <Property Id='ARPPRODUCTICON' Value='fiv.ico' /> + <Property Id='ARPURLINFOABOUT' Value='@ProjectURL@' /> + + <UIRef Id='WixUI_Minimal' /> + <!-- This isn't supported by msitools, but is necessary for WiX. + <WixVariable Id='WixUILicenseRtf' Value='License.rtf' /> + --> + + <Directory Id='TARGETDIR' Name='SourceDir'> + <Directory Id='$(var.ProgramFilesFolder)'> + <Directory Id='INSTALLDIR' Name='$(var.FullName)' /> + </Directory> + + <Directory Id='ProgramMenuFolder'> + <Directory Id='ProgramMenuDir' Name='$(var.FullName)' /> + </Directory> + + <Directory Id='DesktopFolder' /> + </Directory> + + <DirectoryRef Id='ProgramMenuDir'> + <Component Id='ProgramMenuDir' Guid='*'> + <Shortcut Id='ProgramsMenuShortcut' + Name='@ProjectName@' + Target='[INSTALLDIR]\fiv.exe' + WorkingDirectory='INSTALLDIR' + Arguments='"%USERPROFILE%"' + Icon='fiv.ico' /> + <RemoveFolder Id='ProgramMenuDir' On='uninstall' /> + <RegistryValue Root='HKCU' + Key='Software\[Manufacturer]\[ProductName]' + Type='string' + Value='' + KeyPath='yes' /> + </Component> + </DirectoryRef> + + <Feature Id='Complete' Level='1'> + <ComponentGroupRef Id='CG.fiv' /> + <ComponentRef Id='ProgramMenuDir' /> + </Feature> + </Product> +</Wix> diff --git a/meson.build b/meson.build index 6f6d156..82e7978 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,8 @@ # vim: noet ts=4 sts=4 sw=4: project('fiv', 'c', default_options : ['c_std=gnu99', 'warning_level=2'], - version : '0.1.0') + version : '0.1.0', + meson_version : '>=0.57') cc = meson.get_compiler('c') add_project_arguments( @@ -25,22 +26,25 @@ libjpegqs = dependency('libjpegqs', required : get_option('libjpegqs'), lcms2 = dependency('lcms2', required : get_option('lcms2')) # Note that only libraw_r is thread-safe, but we'll just run it out-of process. libraw = dependency('libraw', required : get_option('libraw')) +# This is a direct runtime dependency, but its usage may be disabled for images. librsvg = dependency('librsvg-2.0', required : get_option('librsvg')) xcursor = dependency('xcursor', required : get_option('xcursor')) libheif = dependency('libheif', required : get_option('libheif')) libtiff = dependency('libtiff-4', required : get_option('libtiff')) -# This is a direct dependency of GTK+, but its usage may be disabled. +# This is a direct dependency of GTK+, but its usage may be disabled for images. gdkpixbuf = dependency('gdk-pixbuf-2.0', required : get_option('gdk-pixbuf')) dependencies = [ dependency('gtk+-3.0'), dependency('pixman-1'), + dependency('epoxy'), - # Wuffs is included as a submodule. + dependency('libjpeg'), dependency('libturbojpeg'), dependency('libwebp'), dependency('libwebpdemux'), dependency('libwebpdecoder', required : false), dependency('libwebpmux'), + # Wuffs is included as a submodule. lcms2, libjpegqs, @@ -53,13 +57,31 @@ dependencies = [ cc.find_library('m', required : false), ] +# As of writing, no pkg-config file is produced, and the plugin is not installed +# by default. The library can be built statically, but it's a bit of a hassle. +have_lcms2_fast_float = false +if not get_option('lcms2fastfloat').disabled() + lcms2ff = dependency('lcms2_fast_float', required : false) + if not lcms2ff.found() + lcms2ff = cc.find_library( + 'lcms2_fast_float', required : get_option('lcms2fastfloat')) + if lcms2ff.found() and not cc.has_header('lcms2_fast_float.h') + error('lcms2_fast_float.h not found') + endif + endif + if lcms2ff.found() + dependencies += lcms2ff + have_lcms2_fast_float = true + endif +endif + # As of writing, the API is unstable, and no pkg-config file is produced. # Trying to wrap Cargo in Meson is a recipe for pain, so no version pinning. have_resvg = false if not get_option('resvg').disabled() resvg = dependency('resvg', required : false) if not resvg.found() - resvg = cc.find_library('libresvg', required : get_option('resvg')) + resvg = cc.find_library('resvg', required : get_option('resvg')) if resvg.found() and not cc.has_header('resvg.h') error('resvg.h not found') endif @@ -73,11 +95,13 @@ endif # XXX: https://github.com/mesonbuild/meson/issues/825 docdir = get_option('datadir') / 'doc' / meson.project_name() application_ns = 'name.janouch.' +application_url = 'https://janouch.name/p/' + meson.project_name() conf = configuration_data() conf.set_quoted('PROJECT_NAME', meson.project_name()) conf.set_quoted('PROJECT_VERSION', '@VCS_TAG@') conf.set_quoted('PROJECT_NS', application_ns) +conf.set_quoted('PROJECT_URL', application_url) conf.set_quoted('PROJECT_DOCDIR', get_option('prefix') / docdir) if win32 conf.set_quoted('PROJECT_DOCDIR', docdir) @@ -85,6 +109,7 @@ endif conf.set('HAVE_JPEG_QS', libjpegqs.found()) conf.set('HAVE_LCMS2', lcms2.found()) +conf.set('HAVE_LCMS2_FAST_FLOAT', have_lcms2_fast_float) conf.set('HAVE_LIBRAW', libraw.found()) conf.set('HAVE_RESVG', have_resvg) conf.set('HAVE_LIBRSVG', librsvg.found()) @@ -118,7 +143,8 @@ if win32 '--width', size, '--height', size, '@INPUT@']) endforeach - icon_ico = custom_target(input : icon_png_list, output : 'fiv.ico', + icon_ico = custom_target('fiv.ico', + output : 'fiv.ico', input : icon_png_list, command : [icotool, '-c', '-o', '@OUTPUT@', '@INPUT@']) rc += windows.compile_resources('fiv.rc', depends : icon_ico) endif @@ -133,23 +159,23 @@ gresources = gnome.compile_resources('resources', tiff_tables = custom_target('tiff-tables.h', output : 'tiff-tables.h', input : 'tiff-tables.db', - command : ['tiff-tables.awk', '@INPUT@'], + # Meson 0.56 chokes on files() as well as on a relative path. + command : [meson.current_source_dir() / 'tiff-tables.awk', '@INPUT@'], capture : true, ) desktops = ['fiv.desktop', 'fiv-browse.desktop'] -exe = executable('fiv', 'fiv.c', 'fiv-view.c', 'fiv-io.c', 'fiv-context-menu.c', +iolib = static_library('fiv-io', 'fiv-io.c', 'fiv-io-cmm.c', 'xdg.c', + tiff_tables, config, + dependencies : dependencies).extract_all_objects(recursive : true) +exe = executable('fiv', 'fiv.c', 'fiv-view.c', 'fiv-context-menu.c', 'fiv-browser.c', 'fiv-sidebar.c', 'fiv-thumbnail.c', 'fiv-collection.c', - 'xdg.c', gresources, rc, config, - install : true, + 'fiv-io-model.c', gresources, rc, config, + objects : iolib, dependencies : dependencies, + install : true, win_subsystem : 'windows', ) -if gdkpixbuf.found() - executable('io-benchmark', 'fiv-io-benchmark.c', 'fiv-io.c', 'xdg.c', - build_by_default : false, - dependencies : [dependencies, gdkpixbuf]) -endif desktops += 'fiv-jpegcrop.desktop' jpegcrop = executable('fiv-jpegcrop', 'fiv-jpegcrop.c', rc, config, @@ -162,38 +188,55 @@ jpegcrop = executable('fiv-jpegcrop', 'fiv-jpegcrop.c', rc, config, ) if get_option('tools').enabled() - # libjq 1.6 lacks a pkg-config file, and there is no release in sight. - # libjq 1.6 is required. - tools_dependencies = [cc.find_library('libjq'), dependency('libpng')] + # libjq has only received a pkg-config file in version 1.7. + # libjq >= 1.6 is required. + tools_dependencies = [ + cc.find_library('jq'), dependency('libpng'), dependency('libraw')] tools_c_args = cc.get_supported_arguments( '-Wno-unused-function', '-Wno-unused-parameter') - foreach tool : ['pnginfo', 'jpeginfo', 'tiffinfo', 'webpinfo', 'bmffinfo'] + foreach tool : ['info', 'pnginfo', 'rawinfo', 'hotpixels'] executable(tool, 'tools/' + tool + '.c', tiff_tables, dependencies : tools_dependencies, c_args: tools_c_args) endforeach + + if gdkpixbuf.found() + executable('benchmark-io', 'tools/benchmark-io.c', + objects : iolib, + dependencies : [dependencies, gdkpixbuf]) + endif endif +# Copying the files to the build directory makes GSettings find them in devenv. gsettings_schemas = ['fiv.gschema.xml'] foreach schema : gsettings_schemas - install_data(schema, - rename : [application_ns + schema], + configure_file( + input : schema, + output : application_ns + schema, + copy : true, + install: true, install_dir : get_option('datadir') / 'glib-2.0' / 'schemas') endforeach # For the purposes of development: make the program find its GSettings schemas. gnome.compile_schemas(depend_files : files(gsettings_schemas)) +gnome.post_install(glib_compile_schemas : true, gtk_update_icon_cache : true) -# Validate various files, if there are tools around to do it. -xmls = ['fiv.svg', 'fiv.manifest', 'resources/resources.gresource.xml'] + \ - gsettings_schemas -xmls += run_command(find_program('sed', required : false, disabler : true), - '-n', 's@.*>\([^<>]*[.]svg\)<.*@resources/\\1@p', +# Meson is broken on Windows and removes the backslashes, so this ends up empty. +symbolics = run_command(find_program('sed', required : false, disabler : true), + '-n', 's@.*>\\([^<>]*[.]svg\\)<.*@resources/\\1@p', configure_file( input : 'resources/resources.gresource.xml', output : 'resources.gresource.xml.stamp', copy : true, - ), capture : true, check : true).stdout().strip().split('\n') + ), capture : true, check : true).stdout().strip() + +# Validate various files, if there are tools around to do it. +xmls = ['fiv.svg', 'fiv.manifest', 'resources/resources.gresource.xml'] + \ + gsettings_schemas +if symbolics != '' + xmls += symbolics.split('\n') +endif xmlwf = find_program('xmlwf', required : false, disabler : true) xmllint = find_program('xmllint', required : false, disabler : true) @@ -217,9 +260,11 @@ if not win32 asciidoctor = find_program('asciidoctor', required : false) a2x = find_program('a2x', required : false) if not asciidoctor.found() and not a2x.found() - error('Neither asciidoctor nor a2x were found') + warning('Neither asciidoctor nor a2x were found, ' + + 'falling back to a substandard manual page generator') endif foreach page : [meson.project_name()] + man_capture = false if asciidoctor.found() command = [asciidoctor, '-b', 'manpage', '-a', 'release-version=' + meson.project_version(), @@ -228,10 +273,17 @@ if not win32 command = [a2x, '--doctype', 'manpage', '--format', 'manpage', '-a', 'release-version=' + meson.project_version(), '-D', '@OUTDIR@', '@INPUT@'] + else + command = ['env', 'LC_ALL=C', + 'asciidoc-release-version=' + meson.project_version(), + 'awk', '-f', files('submodules/liberty/tools/asciiman.awk'), + '@INPUT@'] + man_capture = true endif custom_target('manpage for ' + page, input : 'docs' / page + '.adoc', output : page + '.1', + capture : man_capture, command : command, install : true, install_dir : get_option('mandir') / 'man1') @@ -243,6 +295,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() @@ -251,20 +329,52 @@ 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')) if not meson.is_cross_build() meson.add_install_script(updater, skip_if_destdir : dynamic_desktops) endif + + # Quick and dirty package generation, lacking dependencies. + packaging = configuration_data({ + 'name' : meson.project_name(), + 'version' : meson.project_version(), + 'summary' : 'Image viewer', + 'author' : 'Přemysl Eric Janouch', + }) + + subdir('submodules/liberty/meson/packaging') elif meson.is_cross_build() + # Note that even compiling /from within MSYS2/ can still be a cross-build. msys2_root = meson.get_external_property('msys2_root') - meson.add_install_script('msys2-cross-install.sh', msys2_root) + meson.add_install_script('msys2-install.sh', msys2_root) + + wxs = configure_file( + input : 'fiv.wxs.in', + output : 'fiv.wxs', + configuration : configuration_data({ + 'ProjectName' : meson.project_name(), + 'ProjectVersion' : meson.project_version(), + 'ProjectURL' : application_url, + }), + ) + custom_target('package', + output : 'fiv.msi', + command : [meson.current_source_dir() / 'msys2-package.sh', + host_machine.cpu(), 'fiv.msi', wxs], + env : ['MESON_BUILD_ROOT=' + meson.current_build_dir(), + 'MESON_SOURCE_ROOT=' + meson.current_source_dir()], + console : true, + build_always_stale : true, + build_by_default : false, + ) - # This is the minimum to run targets from msys2-cross-configure.sh builds. + # This is the minimum to run targets from msys2-configure.sh builds. meson.add_devenv({ 'WINEPATH' : msys2_root / 'bin', 'XDG_DATA_DIRS' : msys2_root / 'share', diff --git a/meson_options.txt b/meson_options.txt index dad40f8..2aa0f9c 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -3,6 +3,8 @@ option('tools', type : 'feature', value : 'disabled', option('lcms2', type : 'feature', value : 'auto', description : 'Build with Little CMS colour management') +option('lcms2fastfloat', type : 'feature', value : 'auto', + description : 'Build with Little CMS fast float plugin support') option('libjpegqs', type : 'feature', value : 'auto', description : 'Build with JPEG Quant Smooth integration') option('libraw', type : 'feature', value : 'auto', diff --git a/msys2-cross-configure.sh b/msys2-configure.sh index 8887928..2f43d0d 100755 --- a/msys2-cross-configure.sh +++ b/msys2-configure.sh @@ -1,8 +1,26 @@ #!/bin/sh -e -# msys2-cross-configure.sh: set up an MSYS2-based cross-compiled Meson build. -# Dependencies: AWK, sed, sha256sum, cURL, bsdtar, +# msys2-configure.sh: set up an MSYS2-based Meson build (x86-64 by default) +# +# Dependencies: AWK, sed, coreutils, cURL, bsdtar (libarchive), # wine64, Meson, mingw-w64-binutils, mingw-w64-gcc, pkg-config -repository=https://repo.msys2.org/mingw/mingw64/ +# +# We support running directly from within MSYS2 on Windows, +# albeit while still downloading a complete copy of runtime depencies. +pkg=${MINGW_PACKAGE_PREFIX:-mingw-w64-x86_64} +prefix=${MSYSTEM_PREFIX:-/mingw64} +repo=https://repo.msys2.org/mingw$prefix + +chost=${MSYSTEM_CHOST:-x86_64-w64-mingw32} +carch=${MSYSTEM_CARCH:-x86_64} +[ "$carch" = "i686" ] && carch=x86 + +if [ -n "$MSYSTEM" ] +then + wine64() { "$@"; } + awk() { command awk -v RS='\r?\n' "$@"; } + pacman -S --needed libarchive $pkg-ca-certificates $pkg-gcc $pkg-icoutils \ + $pkg-librsvg $pkg-meson $pkg-msitools $pkg-pkgconf +fi status() { echo "$(tput bold)-- $*$(tput sgr0)" @@ -10,7 +28,7 @@ status() { dbsync() { status Fetching repository DB - [ -f db.tsv ] || curl -# "$repository/mingw64.db" | bsdtar -xOf- | awk ' + [ -f db.tsv ] || curl -# "$repo$prefix.db" | bsdtar -xOf- | awk ' function flush() { print f["%NAME%"] f["%FILENAME%"] f["%DEPENDS%"] } NR > 1 && $0 == "%FILENAME%" { flush(); for (i in f) delete f[i] } !/^[^%]/ { field = $0; next } { f[field] = f[field] $0 "\t" } @@ -28,10 +46,11 @@ fetch() { } BEGIN { while ((getline < "db.tsv") > 0) { filenames[$1] = $2; deps[$1] = ""; for (i = 3; i <= NF; i++) { gsub(/[<=>].*/, "", $i); deps[$1] = deps[$1] $i FS } - } for (i = 0; i < ARGC; i++) get(ARGV[i]) }' "$@" | while IFS= read -r name + } for (i = 0; i < ARGC; i++) get(ARGV[i]) }' "$@" | tee db.want | \ + while IFS= read -r name do status Fetching "$name" - [ -f "packages/$name" ] || curl -#o "packages/$name" "$repository/$name" + [ -f "packages/$name" ] || curl -#o "packages/$name" "$repo/$name" done version=$(curl -# https://exiftool.org/ver.txt) @@ -51,9 +70,10 @@ extract() { for subdir in * do [ -d "$subdir" -a "$subdir" != packages ] && rm -rf -- "$subdir" done - for i in packages/* - do bsdtar -xf "$i" --strip-components 1 mingw64 - done + while IFS= read -r name + do bsdtar -xf "packages/$name" --strip-components 1 \ + --exclude '*/share/man' --exclude '*/share/doc' + done < db.want bsdtar -xf exiftool.tar.gz mv Image-ExifTool-*/exiftool bin @@ -74,49 +94,50 @@ configure() { setup() { status Setting up Meson + wrap=true pclibdir=$msys2_root/share/pkgconfig:$msys2_root/lib/pkgconfig + [ -n "$MSYSTEM" ] && \ + wrap=false pclibdir="$(pwd -W)/share/pkgconfig;$(pwd -W)/lib/pkgconfig" + cat >"$toolchain" <<-EOF [binaries] - c = 'x86_64-w64-mingw32-gcc' - cpp = 'x86_64-w64-mingw32-g++' - ar = 'x86_64-w64-mingw32-gcc-ar' - ranlib = 'x86_64-w64-mingw32-gcc-ranlib' - strip = 'x86_64-w64-mingw32-strip' - windres = 'x86_64-w64-mingw32-windres' + c = '$chost-gcc' + cpp = '$chost-g++' + ar = '$chost-gcc-ar' + ranlib = '$chost-gcc-ranlib' + strip = '$chost-strip' + windres = '$chost-windres' pkgconfig = 'pkg-config' [properties] sys_root = '$builddir' msys2_root = '$msys2_root' - pkg_config_libdir = '$msys2_root/share/pkgconfig:$msys2_root/lib/pkgconfig' - needs_exe_wrapper = true + pkg_config_libdir = '$pclibdir' + needs_exe_wrapper = $wrap [host_machine] system = 'windows' - cpu_family = 'x86_64' - cpu = 'x86_64' + cpu_family = '$carch' + cpu = '$carch' endian = 'little' EOF - meson --buildtype=debugoptimized --prefix="$packagedir" \ + meson setup --buildtype=debugoptimized --prefix=/ \ --bindir . --libdir . --cross-file="$toolchain" "$builddir" "$sourcedir" } sourcedir=$(realpath "${2:-$(dirname "$0")}") builddir=$(realpath "${1:-builddir}") -packagedir=$builddir/package toolchain=$builddir/msys2-cross-toolchain.meson # This directory name matches the prefix in .pc files, so we don't need to # modify them (pkgconf has --prefix-variable, but Meson can't pass that option). -msys2_root=$builddir/mingw64 +msys2_root=$builddir$prefix mkdir -p "$msys2_root" cd "$msys2_root" dbsync -fetch mingw-w64-x86_64-gtk3 mingw-w64-x86_64-lcms2 \ - mingw-w64-x86_64-libraw mingw-w64-x86_64-libheif \ - mingw-w64-x86_64-perl mingw-w64-x86_64-perl-win32-api \ - mingw-w64-x86_64-libwinpthread-git # Because we don't do "provides"? +fetch $pkg-gtk3 $pkg-lcms2 $pkg-libraw $pkg-libheif $pkg-libjxl $pkg-perl \ + $pkg-perl-win32-api $pkg-libwinpthread-git # Because we don't do "provides"? verify extract configure diff --git a/msys2-cross-install.sh b/msys2-install.sh index 7cbc2cc..da2d2f1 100755 --- a/msys2-cross-install.sh +++ b/msys2-install.sh @@ -3,6 +3,13 @@ export LC_ALL=C cd "$MESON_INSTALL_DESTDIR_PREFIX" msys2_root=$1 +# Support running directly from within MSYS2 on Windows. +if [ -n "$MSYSTEM" ] +then + wine64() { "$@"; } + awk() { command awk -v RS='\r?\n' "$@"; } +fi + # Copy binaries we directly or indirectly depend on. cp -p "$msys2_root"/bin/*.dll . cp -p "$msys2_root"/bin/wperl.exe . @@ -16,12 +23,13 @@ cp -pR "$msys2_root"/lib/gdk-pixbuf-2.0/ lib cp -pR "$msys2_root"/lib/perl5/ lib mkdir -p share/glib-2.0/schemas cp -pR "$msys2_root"/share/glib-2.0/schemas/*.Settings.* share/glib-2.0/schemas -mkdir -p share -cp -pR "$msys2_root"/share/mime/ share mkdir -p share/icons cp -pR "$msys2_root"/share/icons/Adwaita/ share/icons mkdir -p share/icons/hicolor cp -p "$msys2_root"/share/icons/hicolor/index.theme share/icons/hicolor +mkdir -p share/mime +# GIO doesn't use the database on Windows, this subset is for us. +find "$msys2_root"/share/mime/ -maxdepth 1 -type f -exec cp -p {} share/mime \; # Remove unreferenced libraries. find lib -name '*.a' -exec rm -- {} + diff --git a/msys2-package.sh b/msys2-package.sh new file mode 100755 index 0000000..363c36a --- /dev/null +++ b/msys2-package.sh @@ -0,0 +1,34 @@ +#!/bin/sh -e +export LC_ALL=C +cd "$MESON_BUILD_ROOT" +arch=$1 msi=$2 files=package-files.wxs destdir=$(pwd)/package +shift 2 + +# We're being passed host_machine.cpu(), which will be either x86 or x86_64. +[ "$arch" = "x86" ] || arch=x64 + +rm -rf "$destdir" +meson install --destdir "$destdir" + +txt2rtf() { + LC_ALL=C.UTF-8 iconv -f utf-8 -t ascii//translit "$@" | awk 'BEGIN { + print "{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0 Tahoma;}}" + print "\\f0\\fs24{\\pard\\sa240" + } { + gsub(/\\/, "\\\\"); gsub(/{/, "\\{"); gsub(/}/, "\\}") + if (!$0) { print "\\par}{\\pard\\sa240"; prefix = "" } + else { print prefix $0; prefix = " " } + } END { + print "\\par}}" + }' +} + +# msitools have this filename hardcoded in UI files, and it's required. +txt2rtf "$MESON_SOURCE_ROOT/LICENSE" > License.rtf + +find "$destdir" -type f \ + | wixl-heat --prefix "$destdir/" --directory-ref INSTALLDIR \ + --component-group CG.fiv --var var.SourceDir > "$files" + +wixl --verbose --arch "$arch" -D SourceDir="$destdir" --ext ui \ + --output "$msi" "$@" "$files" diff --git a/resources/cross-large-symbolic.svg b/resources/cross-large-symbolic.svg new file mode 100644 index 0000000..b9b8f9a --- /dev/null +++ b/resources/cross-large-symbolic.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> + <path d="m 1.980469 2 h 1 h 0.03125 c 0.253906 0.011719 0.511719 0.128906 0.6875 0.3125 l 4.28125 4.28125 l 4.3125 -4.28125 c 0.265625 -0.230469 0.445312 -0.304688 0.6875 -0.3125 h 1 v 1 c 0 0.285156 -0.035157 0.550781 -0.25 0.75 l -4.28125 4.28125 l 4.25 4.25 c 0.1875 0.1875 0.28125 0.453125 0.28125 0.71875 v 1 h -1 c -0.265625 0 -0.53125 -0.09375 -0.71875 -0.28125 l -4.28125 -4.28125 l -4.28125 4.28125 c -0.1875 0.1875 -0.453125 0.28125 -0.71875 0.28125 h -1 v -1 c 0 -0.265625 0.09375 -0.53125 0.28125 -0.71875 l 4.28125 -4.25 l -4.28125 -4.28125 c -0.210938 -0.195312 -0.304688 -0.46875 -0.28125 -0.75 z m 0 0" fill="#222222"/> +</svg> diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml index b3d6b1c..3e4aef3 100644 --- a/resources/resources.gresource.xml +++ b/resources/resources.gresource.xml @@ -4,7 +4,9 @@ <file alias="LICENSE">../LICENSE</file> </gresource> <gresource prefix="/org/gnome/design/IconLibrary/scalable/actions/"> + <file preprocess="xml-stripblanks">text-symbolic.svg</file> <file preprocess="xml-stripblanks">circle-filled-symbolic.svg</file> + <file preprocess="xml-stripblanks">cross-large-symbolic.svg</file> <file preprocess="xml-stripblanks">funnel-symbolic.svg</file> <file preprocess="xml-stripblanks">blend-tool-symbolic.svg</file> <file preprocess="xml-stripblanks">checkerboard-symbolic.svg</file> diff --git a/resources/text-symbolic.svg b/resources/text-symbolic.svg new file mode 100644 index 0000000..6528635 --- /dev/null +++ b/resources/text-symbolic.svg @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <filter id="a" height="100%" width="100%" x="0%" y="0%"> + <feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> + </filter> + <mask id="b"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.3"/> + </g> + </mask> + <clipPath id="c"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="d"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/> + </g> + </mask> + <clipPath id="e"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="f"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/> + </g> + </mask> + <clipPath id="g"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="h"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/> + </g> + </mask> + <clipPath id="i"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="j"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/> + </g> + </mask> + <clipPath id="k"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="l"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/> + </g> + </mask> + <clipPath id="m"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="n"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.05"/> + </g> + </mask> + <clipPath id="o"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="p"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.3"/> + </g> + </mask> + <clipPath id="q"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="r"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.5"/> + </g> + </mask> + <clipPath id="s"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="t"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.4"/> + </g> + </mask> + <clipPath id="u"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="v"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.4"/> + </g> + </mask> + <clipPath id="w"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="x"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.5"/> + </g> + </mask> + <clipPath id="y"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <mask id="z"> + <g filter="url(#a)"> + <path d="m 0 0 h 16 v 16 h -16 z" fill-opacity="0.5"/> + </g> + </mask> + <clipPath id="A"> + <path d="m 0 0 h 1024 v 800 h -1024 z"/> + </clipPath> + <g clip-path="url(#c)" mask="url(#b)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 562.460938 212.058594 h 10.449218 c -1.183594 0.492187 -1.296875 2.460937 0 3 h -10.449218 z m 0 0" fill="#2e3436"/> + </g> + <g clip-path="url(#e)" mask="url(#d)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 16 632 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/> + </g> + <g clip-path="url(#g)" mask="url(#f)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 17 631 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/> + </g> + <g clip-path="url(#i)" mask="url(#h)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 18 634 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/> + </g> + <g clip-path="url(#k)" mask="url(#j)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 16 634 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/> + </g> + <g clip-path="url(#m)" mask="url(#l)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 17 635 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/> + </g> + <g clip-path="url(#o)" mask="url(#n)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 19 635 h 1 v 1 h -1 z m 0 0" fill="#2e3436" fill-rule="evenodd"/> + </g> + <path d="m 6 1 l -5 14 h 3 c 1.484375 -4 0.023438 0 1.507812 -4 h 4.984376 l 1.507812 4 h 3 l -5 -14 z m 2 3 l 2.023438 5 h -4 z m 0 0" fill="#2e3436"/> + <g clip-path="url(#q)" mask="url(#p)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 136 660 v 7 h 7 v -7 z m 0 0" fill="#2e3436"/> + </g> + <g clip-path="url(#s)" mask="url(#r)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 199 642 h 3 v 12 h -3 z m 0 0" fill="#2e3436"/> + </g> + <g clip-path="url(#u)" mask="url(#t)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 209.5 144.160156 c 0.277344 0 0.5 0.222656 0.5 0.5 v 1 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -1 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 z m 0 0" fill="#2e3436"/> + </g> + <g clip-path="url(#w)" mask="url(#v)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 206.5 144.160156 c 0.277344 0 0.5 0.222656 0.5 0.5 v 1 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -1 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 z m 0 0" fill="#2e3436"/> + </g> + <g clip-path="url(#y)" mask="url(#x)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 229.5 143.160156 c -0.546875 0 -1 0.457032 -1 1 c 0 0.546875 0.453125 1 1 1 s 1 -0.453125 1 -1 c 0 -0.542968 -0.453125 -1 -1 -1 z m 0 0" fill="#2e3436"/> + </g> + <g clip-path="url(#A)" mask="url(#z)" transform="matrix(1 0 0 1 -56 -640)"> + <path d="m 226.453125 143.160156 c -0.519531 0 -0.953125 0.433594 -0.953125 0.953125 v 0.09375 c 0 0.519531 0.433594 0.953125 0.953125 0.953125 h 0.09375 c 0.519531 0 0.953125 -0.433594 0.953125 -0.953125 v -0.09375 c 0 -0.519531 -0.433594 -0.953125 -0.953125 -0.953125 z m 0 0" fill="#2e3436"/> + </g> +</svg> diff --git a/submodules/liberty b/submodules/liberty new file mode 160000 +Subproject 0e86ffe7c30a4d52eea35856b792567ca1040f5 diff --git a/submodules/wuffs-mirror-release-c b/submodules/wuffs-mirror-release-c new file mode 160000 +Subproject c63c4a9348fb1b52a9b60a6eb62328a97d979d9 diff --git a/subprojects/libjpegqs.wrap b/subprojects/libjpegqs.wrap index c6a8f6f..6a8085b 100644 --- a/subprojects/libjpegqs.wrap +++ b/subprojects/libjpegqs.wrap @@ -1,8 +1,8 @@ [wrap-file] -directory = jpeg-quantsmooth-1.20210408 -source_url = https://github.com/ilyakurdyukov/jpeg-quantsmooth/archive/refs/tags/1.20210408.tar.gz -source_filename = jpeg-quantsmooth-1.20210408.tar.gz -source_hash = 5937ca26db33888cab8638c1a8dc7a367a953bd0857ceb1290d5abc6febf3116 +directory = jpeg-quantsmooth-1.20230818 +source_url = https://github.com/ilyakurdyukov/jpeg-quantsmooth/archive/refs/tags/1.20230818.tar.gz +source_filename = jpeg-quantsmooth-1.20230818.tar.gz +source_hash = ff9a62e8560851648c60d84b3d97ebd9769f01ce6b995779e071d19a759eca06 patch_directory = libjpegqs [provide] diff --git a/subprojects/packagefiles/libjpegqs/meson.build b/subprojects/packagefiles/libjpegqs/meson.build index 03c219b..82377c8 100644 --- a/subprojects/packagefiles/libjpegqs/meson.build +++ b/subprojects/packagefiles/libjpegqs/meson.build @@ -1,8 +1,6 @@ # vim: noet ts=4 sts=4 sw=4: project('jpeg-qs', 'c') -add_project_arguments(meson.get_compiler('c') - .get_supported_arguments('-Wno-misleading-indentation'), - '-DWITH_LOG', language : 'c') +add_project_arguments('-DWITH_LOG', language : 'c') deps = [ dependency('libjpeg'), diff --git a/tiff-tables.awk b/tiff-tables.awk index 2d93c36..29b462b 100755 --- a/tiff-tables.awk +++ b/tiff-tables.awk @@ -2,6 +2,22 @@ BEGIN { FS = ", *" print "// Generated by tiff-tables.awk. DO NOT MODIFY." + print "" + print "#ifndef TIFF_TABLES_CONSTANTS_ONLY" + print "#include <stddef.h>" + print "#include <stdint.h>" + print "" + print "struct tiff_value {" + print "\tconst char *name;" + print "\tuint16_t value;" + print "};" + print "" + print "struct tiff_entry {" + print "\tconst char *name;" + print "\tuint16_t tag;" + print "\tstruct tiff_value *values;" + print "};" + print "#endif" } { @@ -55,8 +71,10 @@ function flushvalues() { function flushsection() { if (section) { flushvalues() - print "};\n\n" allvalues "static struct tiff_entry " \ + print "};\n\n" allvalues "#ifndef TIFF_TABLES_CONSTANTS_ONLY" + print "static struct tiff_entry " \ sectionsnakecase "_entries[] = {" fields "\n\t{}\n};" + print "#endif" } } diff --git a/tiff-tables.db b/tiff-tables.db index 8b0e206..341c36e 100644 --- a/tiff-tables.db +++ b/tiff-tables.db @@ -30,8 +30,14 @@ # Exif Version 2.32 (2019) # https://www.cipa.jp/e/std/std-sec.html # +# ISO/DIS 12234-2 (TIFF/EP) (2000-06-21) +# http://www.barrypearson.co.uk/top2009/downloads/TAG2000-22_DIS12234-2.pdf +# # Digital Negative (DNG) Specification 1.5.0.0 (2019) # https://www.adobe.com/content/dam/acom/en/products/photoshop/pdfs/dng_spec_1.5.0.0.pdf +# +# CIPA DC-007-2021 (Multi-Picture Format) +# https://www.cipa.jp/e/std/std-sec.html # TIFF 6.0 = TIFF @@ -64,6 +70,8 @@ 6, YCbCr 8, CIELab 9, ICCLab # Adobe PageMaker 6.0 TIFF Technical Notes + 32803, Color filter array # DIS/ISO 12234-2 + DNG 1.5.0.0 + 34892, LinearRaw # DNG 1.5.0.0 263, Threshholding 1, No dithering or halftoning 2, Ordered dither or halftoning @@ -185,6 +193,9 @@ 532, ReferenceBlackWhite 700, XMP # Adobe XMP Specification Part 3 Table 12/13/39 32781, ImageID # Adobe PageMaker 6.0 TIFF Technical Notes +33421, CFARepeatPatternDim # DIS/ISO 12234-2 +33422, CFAPattern # DIS/ISO 12234-2 +33423, BatteryLevel # DIS/ISO 12234-2 33432, Copyright # TODO(p): Extract IPTC DataSets, like we do directly with PSIRs. 33723, IPTC # Adobe XMP Specification Part 3 Table 12/39 @@ -192,7 +203,18 @@ 34377, Photoshop # Adobe XMP Specification Part 3 Table 12/39 34665, Exif IFD Pointer # Exif 2.3 34853, GPS Info IFD Pointer # Exif 2.3 -37398, TIFF/EP StandardID # ISO 12234 TIFF/EP image data format +37398, TIFF/EP StandardID # DIS/ISO 12234-2 +37399, SensingMethod # DIS/ISO 12234-2, similar to Exif 41495 + 0, Undefined + 1, Monochrome area sensor + 2, One-chip color area sensor + 3, Two-chip color area sensor + 4, Three-chip color area sensor + 5, Color sequential area sensor + 6, Monochrome linear sensor + 7, Trilinear sensor + 8, Color sequential linear sensor +# TODO(p): Add more TIFF/EP tags that can be only in IFD0. 37724, ImageSourceData # Adobe Photoshop TIFF Technical Notes 50706, DNGVersion # DNG 1.5.0.0 50707, DNGBackwardVersion # DNG 1.5.0.0 @@ -425,3 +447,25 @@ # Exif 2.3 4.6.7 (Notice it starts at 1, and collides with GPS.) = Exif Interoperability 1, InteroperabilityIndex + +# CIPA DC-007-2021 5.2.3., 5.2.4. (But derive "field names" from "tag names".) += MPF +45056, MP Format Version Number # MPFVersion +45057, Number of Images # NumberOfImages +45058, MP Entry # MPEntry +45059, Individual Image Unique ID List # ImageUIDList +45060, Total Number of Captured Frames # TotalFrames +45313, MP Individual Image Number # MPIndividualNum +45569, Panorama Scanning Orientation # PanOrientation +45570, Panorama Horizontal Overlap # PanOverlap_H +45571, Panorama Vertical Overlap # PanOverlap_V +45572, Base Viewpoint Number # BaseViewpointNum +45573, Convergence Angle # ConvergenceAngle +45574, Baseline Length # BaselineLength +45575, Divergence Angle # VerticalDivergence +45576, Horizontal Axis Distance # AxisDistance_X +45577, Vertical Axis Distance # AxisDistance_Y +45578, Collimation Axis Distance # AxisDistance_Z +45579, Yaw Angle # YawAngle +45580, Pitch Angle # PitchAngle +45581, Roll Angle # RollAngle diff --git a/tiffer.h b/tiffer.h new file mode 100644 index 0000000..870ad26 --- /dev/null +++ b/tiffer.h @@ -0,0 +1,356 @@ +// +// tiffer.h: TIFF reading utilities +// +// Copyright (c) 2021 - 2023, 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 <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <string.h> + +// --- Utilities --------------------------------------------------------------- + +static uint64_t +tiffer_u64be(const uint8_t *p) +{ + return (uint64_t) p[0] << 56 | (uint64_t) p[1] << 48 | + (uint64_t) p[2] << 40 | (uint64_t) p[3] << 32 | + (uint64_t) p[4] << 24 | p[5] << 16 | p[6] << 8 | p[7]; +} + +static uint32_t +tiffer_u32be(const uint8_t *p) +{ + return (uint32_t) p[0] << 24 | p[1] << 16 | p[2] << 8 | p[3]; +} + +static uint16_t +tiffer_u16be(const uint8_t *p) +{ + return (uint16_t) p[0] << 8 | p[1]; +} + +static uint64_t +tiffer_u64le(const uint8_t *p) +{ + return (uint64_t) p[7] << 56 | (uint64_t) p[6] << 48 | + (uint64_t) p[5] << 40 | (uint64_t) p[4] << 32 | + (uint64_t) p[3] << 24 | p[2] << 16 | p[1] << 8 | p[0]; +} + +static uint32_t +tiffer_u32le(const uint8_t *p) +{ + return (uint32_t) p[3] << 24 | p[2] << 16 | p[1] << 8 | p[0]; +} + +static uint16_t +tiffer_u16le(const uint8_t *p) +{ + return (uint16_t) p[1] << 8 | p[0]; +} + +// --- TIFF -------------------------------------------------------------------- +// libtiff is a mess, and the format is not particularly complicated. +// Exiv2 is senselessly copylefted, and cannot do much. +// libexif is only marginally better. +// ExifTool is too user-oriented. + +struct un { + uint64_t (*u64) (const uint8_t *); + uint32_t (*u32) (const uint8_t *); + uint16_t (*u16) (const uint8_t *); +}; + +static struct un tiffer_unbe = {tiffer_u64be, tiffer_u32be, tiffer_u16be}; +static struct un tiffer_unle = {tiffer_u64le, tiffer_u32le, tiffer_u16le}; + +struct tiffer { + struct un *un; + const uint8_t *begin, *p, *end; + uint16_t remaining_fields; +}; + +static bool +tiffer_u32(struct tiffer *self, uint32_t *u) +{ + if (self->end - self->p < 4) + return false; + + *u = self->un->u32(self->p); + self->p += 4; + return true; +} + +static bool +tiffer_u16(struct tiffer *self, uint16_t *u) +{ + if (self->end - self->p < 2) + return false; + + *u = self->un->u16(self->p); + self->p += 2; + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +tiffer_init(struct tiffer *self, const uint8_t *tiff, size_t len) +{ + self->un = NULL; + self->begin = self->p = tiff; + self->end = tiff + len; + self->remaining_fields = 0; + + const uint8_t + le[4] = {'I', 'I', 42, 0}, + be[4] = {'M', 'M', 0, 42}; + + if (tiff + 8 > self->end) + return false; + else if (!memcmp(tiff, le, sizeof le)) + self->un = &tiffer_unle; + else if (!memcmp(tiff, be, sizeof be)) + self->un = &tiffer_unbe; + else + return false; + + self->p = tiff + 4; + // The first IFD needs to be read by caller explicitly, + // even though it's required to be present by TIFF 6.0. + return true; +} + +/// Read the next IFD in a sequence. +static bool +tiffer_next_ifd(struct tiffer *self) +{ + // All fields from any previous IFD need to be read first. + if (self->remaining_fields) + return false; + + uint32_t ifd_offset = 0; + if (!tiffer_u32(self, &ifd_offset)) + return false; + + // There is nothing more to read, this chain has terminated. + if (!ifd_offset) + return false; + + // Note that TIFF 6.0 requires there to be at least one entry, + // but there is no need for us to check it. + self->p = self->begin + ifd_offset; + return tiffer_u16(self, &self->remaining_fields); +} + +static size_t +tiffer_length(const struct tiffer *self) +{ + return self->begin > self->end ? 0 : self->end - self->begin; +} + +/// Initialize a derived TIFF reader for a subIFD at the given location. +static bool +tiffer_subifd( + const struct tiffer *self, uint32_t offset, struct tiffer *subreader) +{ + if (tiffer_length(self) < offset) + return false; + + *subreader = *self; + subreader->p = subreader->begin + offset; + return tiffer_u16(subreader, &subreader->remaining_fields); +} + +enum tiffer_type { + TIFFER_BYTE = 1, TIFFER_ASCII, TIFFER_SHORT, TIFFER_LONG, + TIFFER_RATIONAL, + TIFFER_SBYTE, TIFFER_UNDEFINED, TIFFER_SSHORT, TIFFER_SLONG, + TIFFER_SRATIONAL, + TIFFER_FLOAT, + TIFFER_DOUBLE, + // This last type from TIFF Technical Note 1 isn't really used much. + TIFFER_IFD, +}; + +static size_t +tiffer_value_size(enum tiffer_type type) +{ + switch (type) { + case TIFFER_BYTE: + case TIFFER_SBYTE: + case TIFFER_ASCII: + case TIFFER_UNDEFINED: + return 1; + case TIFFER_SHORT: + case TIFFER_SSHORT: + return 2; + case TIFFER_LONG: + case TIFFER_SLONG: + case TIFFER_FLOAT: + case TIFFER_IFD: + return 4; + case TIFFER_RATIONAL: + case TIFFER_SRATIONAL: + case TIFFER_DOUBLE: + return 8; + default: + return 0; + } +} + +/// A lean iterator for values within entries. +struct tiffer_entry { + uint16_t tag; + enum tiffer_type type; + // For {S,}BYTE, ASCII, UNDEFINED, use these fields directly. + const uint8_t *p; + uint32_t remaining_count; +}; + +static bool +tiffer_next_value(struct tiffer_entry *entry) +{ + if (!entry->remaining_count) + return false; + + entry->p += tiffer_value_size(entry->type); + entry->remaining_count--; + return true; +} + +static bool +tiffer_integer( + const struct tiffer *self, const struct tiffer_entry *entry, int64_t *out) +{ + if (!entry->remaining_count) + return false; + + // Somewhat excessively lenient, intended for display. + // TIFF 6.0 only directly suggests that a reader is should accept + // any of BYTE/SHORT/LONG for unsigned integers. + switch (entry->type) { + case TIFFER_BYTE: + case TIFFER_ASCII: + case TIFFER_UNDEFINED: + *out = *entry->p; + return true; + case TIFFER_SBYTE: + *out = (int8_t) *entry->p; + return true; + case TIFFER_SHORT: + *out = self->un->u16(entry->p); + return true; + case TIFFER_SSHORT: + *out = (int16_t) self->un->u16(entry->p); + return true; + case TIFFER_LONG: + case TIFFER_IFD: + *out = self->un->u32(entry->p); + return true; + case TIFFER_SLONG: + *out = (int32_t) self->un->u32(entry->p); + return true; + default: + return false; + } +} + +static bool +tiffer_rational(const struct tiffer *self, const struct tiffer_entry *entry, + int64_t *numerator, int64_t *denominator) +{ + if (!entry->remaining_count) + return false; + + // Somewhat excessively lenient, intended for display. + switch (entry->type) { + case TIFFER_RATIONAL: + *numerator = self->un->u32(entry->p); + *denominator = self->un->u32(entry->p + 4); + return true; + case TIFFER_SRATIONAL: + *numerator = (int32_t) self->un->u32(entry->p); + *denominator = (int32_t) self->un->u32(entry->p + 4); + return true; + default: + if (tiffer_integer(self, entry, numerator)) { + *denominator = 1; + return true; + } + return false; + } +} + +static bool +tiffer_real( + const struct tiffer *self, const struct tiffer_entry *entry, double *out) +{ + if (!entry->remaining_count) + return false; + + // Somewhat excessively lenient, intended for display. + // Assuming the host architecture uses IEEE 754. + switch (entry->type) { + int64_t numerator, denominator; + case TIFFER_FLOAT: + *out = *(float *) entry->p; + return true; + case TIFFER_DOUBLE: + *out = *(double *) entry->p; + return true; + default: + if (tiffer_rational(self, entry, &numerator, &denominator)) { + *out = (double) numerator / denominator; + return true; + } + return false; + } +} + +static bool +tiffer_next_entry(struct tiffer *self, struct tiffer_entry *entry) +{ + if (!self->remaining_fields) + return false; + + uint16_t type = entry->type = 0xFFFF; + if (!tiffer_u16(self, &entry->tag) || !tiffer_u16(self, &type) || + !tiffer_u32(self, &entry->remaining_count)) + return false; + + // Short values may and will be inlined, rather than pointed to. + size_t values_size = tiffer_value_size(type) * entry->remaining_count; + uint32_t offset = 0; + if (values_size <= sizeof offset) { + entry->p = self->p; + self->p += sizeof offset; + } else if (tiffer_u32(self, &offset) && tiffer_length(self) >= offset) { + entry->p = self->begin + offset; + } else { + return false; + } + + // All entries are pre-checked not to overflow. + if (values_size > PTRDIFF_MAX || + self->end - entry->p < (ptrdiff_t) values_size) + return false; + + // Setting it at the end may provide an indication while debugging. + entry->type = type; + self->remaining_fields--; + return true; +} diff --git a/fiv-io-benchmark.c b/tools/benchmark-io.c index 00406cd..3dadaae 100644 --- a/fiv-io-benchmark.c +++ b/tools/benchmark-io.c @@ -1,7 +1,7 @@ // -// fiv-io-benchmark.c: see if we suck +// benchmark-io.c: measure and compare image loading times // -// Copyright (c) 2021 - 2022, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2023, 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. @@ -32,35 +32,36 @@ timestamp(void) static void one_file(const char *filename) { - double since_us = timestamp(); + GFile *file = g_file_new_for_commandline_arg(filename); + double since_us = timestamp(), us = 0; FivIoOpenContext ctx = { - .uri = g_filename_to_uri(filename, NULL, NULL), + .uri = g_file_get_uri(file), .screen_dpi = 96, // Only using this array as a redirect. .warnings = g_ptr_array_new_with_free_func(g_free), }; - cairo_surface_t *loaded_by_us = fiv_io_open(&ctx, NULL); + FivIoImage *loaded_by_us = fiv_io_open(&ctx, NULL); + g_clear_object(&file); g_free((char *) ctx.uri); g_ptr_array_free(ctx.warnings, TRUE); if (!loaded_by_us) return; - cairo_surface_destroy(loaded_by_us); - double us = timestamp() - since_us; + fiv_io_image_unref(loaded_by_us); + us = timestamp() - since_us; - double since_pixbuf = timestamp(); + double since_pixbuf = timestamp(), pixbuf = 0; GdkPixbuf *gdk_pixbuf = gdk_pixbuf_new_from_file(filename, NULL); - if (!gdk_pixbuf) - return; - - cairo_surface_t *loaded_by_pixbuf = - gdk_cairo_surface_create_from_pixbuf(gdk_pixbuf, 1, NULL); - g_object_unref(gdk_pixbuf); - cairo_surface_destroy(loaded_by_pixbuf); - double pixbuf = timestamp() - since_pixbuf; + if (gdk_pixbuf) { + cairo_surface_t *loaded_by_pixbuf = + gdk_cairo_surface_create_from_pixbuf(gdk_pixbuf, 1, NULL); + g_object_unref(gdk_pixbuf); + cairo_surface_destroy(loaded_by_pixbuf); + pixbuf = timestamp() - since_pixbuf; + } - printf("%f\t%f\t%.0f%%\t%s\n", us, pixbuf, us / pixbuf * 100, filename); + printf("%.3f\t%.3f\t%.0f%%\t%s\n", us, pixbuf, us / pixbuf * 100, filename); } int diff --git a/tools/bmffinfo.c b/tools/bmffinfo.c deleted file mode 100644 index f0c6ff1..0000000 --- a/tools/bmffinfo.c +++ /dev/null @@ -1,142 +0,0 @@ -// -// bmffinfo.c: acquire information about BMFF files in JSON format -// -// Copyright (c) 2021, 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 "info.h" - -#include <jv.h> - -#include <errno.h> -#include <stdio.h> -#include <stdlib.h> -#include <string.h> - -// --- ISO/IEC base media file format ------------------------------------------ -// ISO/IEC 14496-12:2015(E), used to be publicly available, now there's only: -// https://mpeg.chiariglione.org/standards/mpeg-4/iso-base-media-file-format/text-isoiec-14496-12-5th-edition -// but people have managed to archive the final version as well: -// https://b.goeswhere.com/ISO_IEC_14496-12_2015.pdf -// -// ISO/IEC 23008-12:2017 Information technology - -// High efficiency coding and media delivery in heterogeneous environments - -// Part 12: Image File Format + Cor 1:2020 Technical Corrigendum 1 -// https://standards.iso.org/ittf/PubliclyAvailableStandards/ - -static jv -parse_bmff_box(jv o, const char *type, const uint8_t *data, size_t len) -{ - // TODO(p): Parse out "uuid"'s uint8_t[16] initial field, present as hex. - // TODO(p): Parse out "ftyp" contents: 14496-12:2015 4.3 - // TODO(p): Parse out other important boxes: 14496-12:2015 8+ - return add_to_subarray(o, "boxes", jv_string(type)); -} - -static jv -parse_bmff(jv o, const uint8_t *p, size_t len) -{ - // 4.2 Object Structure--this box need not be present, nor at the beginning - // TODO(p): What does `aligned(8)` mean? It's probably in bits. - if (len < 8 || memcmp(p + 4, "ftyp", 4)) - return add_error(o, "not BMFF at all or unsupported"); - - const uint8_t *end = p + len; - while (p < end) { - if (end - p < 8) { - o = add_warning(o, "box framing mismatch"); - break; - } - - char type[5] = ""; - memcpy(type, p + 4, 4); - - uint64_t box_size = u32be(p); - const uint8_t *data = p + 8; - if (box_size == 1) { - if (end - p < 16) { - o = add_warning(o, "unexpected EOF"); - break; - } - box_size = u64be(data); - data += 8; - } else if (!box_size) - box_size = end - p; - - if (box_size > (uint64_t) (end - p)) { - o = add_warning(o, "unexpected EOF"); - break; - } - - size_t data_len = box_size - (data - p); - o = parse_bmff_box(o, type, data, data_len); - p += box_size; - } - return o; -} - -// --- I/O --------------------------------------------------------------------- - -static jv -do_file(const char *filename, jv o) -{ - const char *err = NULL; - FILE *fp = fopen(filename, "rb"); - if (!fp) { - err = strerror(errno); - goto error; - } - - uint8_t *data = NULL, buf[256 << 10]; - size_t n, len = 0; - while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) { - data = realloc(data, len + n); - memcpy(data + len, buf, n); - len += n; - } - if (ferror(fp)) { - err = strerror(errno); - goto error_read; - } - - o = parse_bmff(o, data, len); -error_read: - fclose(fp); - free(data); -error: - if (err) - o = add_error(o, err); - return o; -} - -int -main(int argc, char *argv[]) -{ - (void) parse_icc; - (void) parse_exif; - (void) parse_psir; - - // XXX: Can't use `xargs -P0`, there's a risk of non-atomic writes. - // Usage: find . -iname *.png -print0 | xargs -0 ./pnginfo - for (int i = 1; i < argc; i++) { - const char *filename = argv[i]; - - jv o = jv_object(); - o = jv_object_set(o, jv_string("filename"), jv_string(filename)); - o = do_file(filename, o); - jv_dumpf(o, stdout, 0 /* Might consider JV_PRINT_SORTED. */); - fputc('\n', stdout); - } - return 0; -} diff --git a/tools/hotpixels.c b/tools/hotpixels.c new file mode 100644 index 0000000..ee1028c --- /dev/null +++ b/tools/hotpixels.c @@ -0,0 +1,210 @@ +// +// hotpixels.c: look for hot pixels in raw image files +// +// Usage: pass a bunch of raw photo images taken with the lens cap on at, +// e.g., ISO 8000-12800 @ 1/20-1/60, and store the resulting file as, +// e.g., Nikon D7500.badpixels, which can then be directly used by Rawtherapee. +// +// Copyright (c) 2023, 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 <libraw.h> + +#if LIBRAW_VERSION < LIBRAW_MAKE_VERSION(0, 21, 0) +#error LibRaw 0.21.0 or newer is required. +#endif + +#include <errno.h> +#include <stdbool.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static void * +xreallocarray(void *o, size_t n, size_t m) +{ + if (m && n > SIZE_MAX / m) { + fprintf(stderr, "xreallocarray: %s\n", strerror(ENOMEM)); + exit(EXIT_FAILURE); + } + void *p = realloc(o, n * m); + if (!p && n && m) { + fprintf(stderr, "xreallocarray: %s\n", strerror(errno)); + exit(EXIT_FAILURE); + } + return p; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct coord { ushort x, y; }; + +static bool +coord_equals(struct coord a, struct coord b) +{ + return a.x == b.x && a.y == b.y; +} + +static int +coord_cmp(const void *a, const void *b) +{ + const struct coord *ca = (const struct coord *) a; + const struct coord *cb = (const struct coord *) b; + return ca->y != cb->y + ? (int) ca->y - (int) cb->y + : (int) ca->x - (int) cb->x; +} + +struct candidates { + struct coord *xy; + size_t len; + size_t alloc; +}; + +static void +candidates_add(struct candidates *c, ushort x, ushort y) +{ + if (c->len == c->alloc) { + c->alloc += 64; + c->xy = xreallocarray(c->xy, sizeof *c->xy, c->alloc); + } + + c->xy[c->len++] = (struct coord) {x, y}; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// A stretch of zeroes that is assumed to mean start of outliers. +#define SPAN 10 + +static const char * +process_raw(struct candidates *c, const uint8_t *p, size_t len) +{ + libraw_data_t *iprc = libraw_init(LIBRAW_OPIONS_NO_DATAERR_CALLBACK); + if (!iprc) + return "failed to obtain a LibRaw handle"; + + int err = 0; + if ((err = libraw_open_buffer(iprc, p, len)) || + (err = libraw_unpack(iprc))) { + libraw_close(iprc); + return libraw_strerror(err); + } + if (!iprc->rawdata.raw_image) { + libraw_close(iprc); + return "only Bayer raws are supported, not Foveon"; + } + + // Make a histogram. + uint64_t bins[USHRT_MAX] = {}; + for (ushort yy = 0; yy < iprc->sizes.height; yy++) { + for (ushort xx = 0; xx < iprc->sizes.width; xx++) { + ushort y = iprc->sizes.top_margin + yy; + ushort x = iprc->sizes.left_margin + xx; + bins[iprc->rawdata.raw_image[y * iprc->sizes.raw_width + x]]++; + } + } + + // Detecting outliers is not completely straight-forward, + // it may help to see the histogram. + if (getenv("HOTPIXELS_HISTOGRAM")) { + for (ushort i = 0; i < USHRT_MAX; i++) + fprintf(stderr, "%u ", (unsigned) bins[i]); + fputc('\n', stderr); + } + + // Go to the first non-zero pixel value. + size_t last = 0; + for (; last < USHRT_MAX; last++) + if (bins[last]) + break; + + // Find the last pixel value we assume to not be hot. + for (; last < USHRT_MAX - SPAN - 1; last++) { + uint64_t nonzero = 0; + for (int i = 1; i <= SPAN; i++) + nonzero += bins[last + i]; + if (!nonzero) + break; + } + + // Store coordinates for all pixels above that value. + for (ushort yy = 0; yy < iprc->sizes.height; yy++) { + for (ushort xx = 0; xx < iprc->sizes.width; xx++) { + ushort y = iprc->sizes.top_margin + yy; + ushort x = iprc->sizes.left_margin + xx; + if (iprc->rawdata.raw_image[y * iprc->sizes.raw_width + x] > last) + candidates_add(c, xx, yy); + } + } + + libraw_close(iprc); + return NULL; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static const char * +do_file(struct candidates *c, const char *filename) +{ + FILE *fp = fopen(filename, "rb"); + if (!fp) + return strerror(errno); + + uint8_t *data = NULL, buf[256 << 10]; + size_t n, len = 0; + while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) { + data = xreallocarray(data, len + n, 1); + memcpy(data + len, buf, n); + len += n; + } + + const char *err = ferror(fp) + ? strerror(errno) + : process_raw(c, data, len); + + fclose(fp); + free(data); + return err; +} + +int +main(int argc, char *argv[]) +{ + struct candidates c = {}; + for (int i = 1; i < argc; i++) { + const char *filename = argv[i], *err = do_file(&c, filename); + if (err) { + fprintf(stderr, "%s: %s\n", filename, err); + return EXIT_FAILURE; + } + } + + qsort(c.xy, c.len, sizeof *c.xy, coord_cmp); + + // If it is detected in all passed photos, it is probably indeed bad. + int count = 1; + for (size_t i = 1; i <= c.len; i++) { + if (i != c.len && coord_equals(c.xy[i - 1], c.xy[i])) { + count++; + continue; + } + + if (count == argc - 1) + printf("%u %u\n", c.xy[i - 1].x, c.xy[i - 1].y); + + count = 1; + } + return 0; +} diff --git a/tools/info.c b/tools/info.c new file mode 100644 index 0000000..440939f --- /dev/null +++ b/tools/info.c @@ -0,0 +1,286 @@ +// +// info.c: acquire information about JPEG/TIFF/BMFF/WebP files in JSON format +// +// Copyright (c) 2021 - 2023, 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 "info.h" + +#include <jv.h> + +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +// --- ISO/IEC base media file format ------------------------------------------ +// ISO/IEC 14496-12:2015(E), used to be publicly available, now there's only: +// https://mpeg.chiariglione.org/standards/mpeg-4/iso-base-media-file-format/text-isoiec-14496-12-5th-edition +// but people have managed to archive the final version as well: +// https://b.goeswhere.com/ISO_IEC_14496-12_2015.pdf +// +// ISO/IEC 23008-12:2017 Information technology - +// High efficiency coding and media delivery in heterogeneous environments - +// Part 12: Image File Format + Cor 1:2020 Technical Corrigendum 1 +// https://standards.iso.org/ittf/PubliclyAvailableStandards/ + +static jv +parse_bmff_box(jv o, const char *type, const uint8_t *data, size_t len) +{ + // TODO(p): Parse out "uuid"'s uint8_t[16] initial field, present as hex. + // TODO(p): Parse out "ftyp" contents: 14496-12:2015 4.3 + // TODO(p): Parse out other important boxes: 14496-12:2015 8+ + return add_to_subarray(o, "boxes", jv_string(type)); +} + +static bool +detect_bmff(const uint8_t *p, size_t len) +{ + // 4.2 Object Structure--this box need not be present, nor at the beginning + // TODO(p): What does `aligned(8)` mean? It's probably in bits. + return len >= 8 && !memcmp(p + 4, "ftyp", 4); +} + +static jv +parse_bmff(jv o, const uint8_t *p, size_t len) +{ + if (!detect_bmff(p, len)) + return add_error(o, "not BMFF at all or unsupported"); + + const uint8_t *end = p + len; + while (p < end) { + if (end - p < 8) { + o = add_warning(o, "box framing mismatch"); + break; + } + + char type[5] = ""; + memcpy(type, p + 4, 4); + + uint64_t box_size = u32be(p); + const uint8_t *data = p + 8; + if (box_size == 1) { + if (end - p < 16) { + o = add_warning(o, "unexpected EOF"); + break; + } + box_size = u64be(data); + data += 8; + } else if (!box_size) + box_size = end - p; + + if (box_size > (uint64_t) (end - p)) { + o = add_warning(o, "unexpected EOF"); + break; + } + + size_t data_len = box_size - (data - p); + o = parse_bmff_box(o, type, data, data_len); + p += box_size; + } + return o; +} + +// --- WebP -------------------------------------------------------------------- +// libwebp won't let us simply iterate over all chunks, so handroll it. +// +// https://github.com/webmproject/libwebp/blob/master/doc/webp-container-spec.txt +// https://github.com/webmproject/libwebp/blob/master/doc/webp-lossless-bitstream-spec.txt +// https://datatracker.ietf.org/doc/html/rfc6386 +// +// Pretty versions, hopefully not outdated: +// https://developers.google.com/speed/webp/docs/riff_container +// https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification + +static bool +detect_webp(const uint8_t *p, size_t len) +{ + return len >= 12 && !memcmp(p, "RIFF", 4) && !memcmp(p + 8, "WEBP", 4); +} + +static jv +parse_webp_vp8(jv o, const uint8_t *p, size_t len) +{ + if (len < 10 || (p[0] & 1) != 0 /* key frame */ || + p[3] != 0x9d || p[4] != 0x01 || p[5] != 0x2a) { + return add_warning(o, "invalid VP8 chunk"); + } + + o = jv_set(o, jv_string("width"), jv_number(u16le(p + 6) & 0x3fff)); + o = jv_set(o, jv_string("height"), jv_number(u16le(p + 8) & 0x3fff)); + return o; +} + +static jv +parse_webp_vp8l(jv o, const uint8_t *p, size_t len) +{ + if (len < 5 || p[0] != 0x2f) + return add_warning(o, "invalid VP8L chunk"); + + // Reading LSB-first from a little endian value means reading in order. + uint32_t header = u32le(p + 1); + o = jv_set(o, jv_string("width"), jv_number((header & 0x3fff) + 1)); + header >>= 14; + o = jv_set(o, jv_string("height"), jv_number((header & 0x3fff) + 1)); + header >>= 14; + o = jv_set(o, jv_string("alpha_is_used"), jv_bool(header & 1)); + return o; +} + +static jv +parse_webp_vp8x(jv o, const uint8_t *p, size_t len) +{ + if (len < 10) + return add_warning(o, "invalid VP8X chunk"); + + // Most of the fields in this chunk are duplicate or inferrable. + // Probably not worth decoding or verifying. + // TODO(p): For animations, we need to use the width and height from here. + uint8_t flags = p[0]; + o = jv_set(o, jv_string("animation"), jv_bool((flags >> 1) & 1)); + return o; +} + +static jv +parse_webp(jv o, const uint8_t *p, size_t len) +{ + if (!detect_webp(p, len)) + return add_error(o, "not a WEBP file"); + + // TODO(p): This can still be parseable. + // TODO(p): Warn on trailing data. + uint32_t size = u32le(p + 4); + if (8 + size < len) + return add_error(o, "truncated file"); + + const uint8_t *end = p + 8 + size; + p += 12; + + jv chunks = jv_array(); + while (p < end) { + if (end - p < 8) { + o = add_warning(o, "framing mismatch"); + printf("%ld", end - p); + break; + } + + uint32_t chunk_size = u32le(p + 4); + uint32_t chunk_advance = (chunk_size + 1) & ~1; + if (p + 8 + chunk_advance > end) { + o = add_warning(o, "runaway chunk payload"); + break; + } + + char fourcc[5] = ""; + memcpy(fourcc, p, 4); + chunks = jv_array_append(chunks, jv_string(fourcc)); + p += 8; + + // TODO(p): Decode more chunks. + if (!strcmp(fourcc, "VP8 ")) + o = parse_webp_vp8(o, p, chunk_size); + if (!strcmp(fourcc, "VP8L")) + o = parse_webp_vp8l(o, p, chunk_size); + if (!strcmp(fourcc, "VP8X")) + o = parse_webp_vp8x(o, p, chunk_size); + if (!strcmp(fourcc, "EXIF")) + o = parse_exif(o, p, chunk_size); + if (!strcmp(fourcc, "ICCP")) + o = parse_icc(o, p, chunk_size); + p += chunk_advance; + } + return jv_set(o, jv_string("chunks"), chunks); +} + +// --- I/O --------------------------------------------------------------------- + +static struct { + const char *name; + bool (*detect) (const uint8_t *, size_t); + jv (*parse) (jv, const uint8_t *, size_t); +} formats[] = { + {"JPEG", detect_jpeg, parse_jpeg}, + {"TIFF", detect_tiff, parse_tiff}, + {"BMFF", detect_bmff, parse_bmff}, + {"WebP", detect_webp, parse_webp}, +}; + +static jv +parse_any(jv o, const uint8_t *p, size_t len) +{ + // TODO(p): Also see if the file extension is appropriate. + for (size_t i = 0; i < sizeof formats / sizeof *formats; i++) { + if (!formats[i].detect(p, len)) + continue; + if (getenv("INFO_IDENTIFY")) + o = jv_set(o, jv_string("format"), jv_string(formats[i].name)); + return formats[i].parse(o, p, len); + } + return add_error(o, "unsupported file format"); +} + +static jv +do_file(const char *filename, jv o) +{ + const char *err = NULL; + FILE *fp = fopen(filename, "rb"); + if (!fp) { + err = strerror(errno); + goto error; + } + + uint8_t *data = NULL, buf[256 << 10]; + size_t n, len = 0; + while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) { + data = realloc(data, len + n); + memcpy(data + len, buf, n); + len += n; + } + if (ferror(fp)) { + err = strerror(errno); + goto error_read; + } + +#if 0 + // Not sure if I want to ensure their existence... + o = jv_object_set(o, jv_string("info"), jv_array()); + o = jv_object_set(o, jv_string("warnings"), jv_array()); +#endif + + o = parse_any(o, data, len); +error_read: + fclose(fp); + free(data); +error: + if (err) + o = add_error(o, err); + return o; +} + +int +main(int argc, char *argv[]) +{ + // XXX: Can't use `xargs -P0`, there's a risk of non-atomic writes. + // Usage: find . -print0 | xargs -0 ./info + for (int i = 1; i < argc; i++) { + const char *filename = argv[i]; + + jv o = jv_object(); + o = jv_object_set(o, jv_string("filename"), jv_string(filename)); + o = do_file(filename, o); + jv_dumpf(o, stdout, 0 /* JV_PRINT_SORTED would discard information. */); + fputc('\n', stdout); + } + return 0; +} diff --git a/tools/info.h b/tools/info.h index 816c9cf..b6c6391 100644 --- a/tools/info.h +++ b/tools/info.h @@ -1,7 +1,7 @@ // // info.h: metadata extraction utilities // -// Copyright (c) 2021, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2021 - 2023, 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. @@ -17,350 +17,14 @@ #include <jv.h> +#include <stdbool.h> #include <stdlib.h> #include <string.h> -#include <stdbool.h> - -// --- Utilities --------------------------------------------------------------- - -static char * -binhex(const uint8_t *data, size_t len) -{ - static const char *alphabet = "0123456789abcdef"; - char *buf = calloc(1, len * 2 + 1), *p = buf; - for (size_t i = 0; i < len; i++) { - *p++ = alphabet[data[i] >> 4]; - *p++ = alphabet[data[i] & 0xF]; - } - return buf; -} - -static uint64_t -u64be(const uint8_t *p) -{ - return (uint64_t) p[0] << 56 | (uint64_t) p[1] << 48 | - (uint64_t) p[2] << 40 | (uint64_t) p[3] << 32 | - (uint64_t) p[4] << 24 | p[5] << 16 | p[6] << 8 | p[7]; -} - -static uint32_t -u32be(const uint8_t *p) -{ - return (uint32_t) p[0] << 24 | p[1] << 16 | p[2] << 8 | p[3]; -} - -static uint16_t -u16be(const uint8_t *p) -{ - return (uint16_t) p[0] << 8 | p[1]; -} - -static uint64_t -u64le(const uint8_t *p) -{ - return (uint64_t) p[7] << 56 | (uint64_t) p[6] << 48 | - (uint64_t) p[5] << 40 | (uint64_t) p[4] << 32 | - (uint64_t) p[3] << 24 | p[2] << 16 | p[1] << 8 | p[0]; -} - -static uint32_t -u32le(const uint8_t *p) -{ - return (uint32_t) p[3] << 24 | p[2] << 16 | p[1] << 8 | p[0]; -} - -static uint16_t -u16le(const uint8_t *p) -{ - return (uint16_t) p[1] << 8 | p[0]; -} - -// --- TIFF -------------------------------------------------------------------- -// libtiff is a mess, and the format is not particularly complicated. -// Exiv2 is senselessly copylefted, and cannot do much. -// libexif is only marginally better. -// ExifTool is too user-oriented. - -static struct un { - uint64_t (*u64) (const uint8_t *); - uint32_t (*u32) (const uint8_t *); - uint16_t (*u16) (const uint8_t *); -} unbe = {u64be, u32be, u16be}, unle = {u64le, u32le, u16le}; - -struct tiffer { - struct un *un; - const uint8_t *begin, *p, *end; - uint16_t remaining_fields; -}; -static bool -tiffer_u32(struct tiffer *self, uint32_t *u) -{ - if (self->p + 4 > self->end) - return false; - *u = self->un->u32(self->p); - self->p += 4; - return true; -} - -static bool -tiffer_u16(struct tiffer *self, uint16_t *u) -{ - if (self->p + 2 > self->end) - return false; - *u = self->un->u16(self->p); - self->p += 2; - return true; -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static bool -tiffer_init(struct tiffer *self, const uint8_t *tiff, size_t len) -{ - self->un = NULL; - self->begin = self->p = tiff; - self->end = tiff + len; - self->remaining_fields = 0; - - const uint8_t - le[4] = {'I', 'I', 42, 0}, - be[4] = {'M', 'M', 0, 42}; - - if (tiff + 8 > self->end) - return false; - else if (!memcmp(tiff, le, sizeof le)) - self->un = &unle; - else if (!memcmp(tiff, be, sizeof be)) - self->un = &unbe; - else - return false; - - self->p = tiff + 4; - // The first IFD needs to be read by caller explicitly, - // even though it's required to be present by TIFF 6.0. - return true; -} - -/// Read the next IFD in a sequence. -static bool -tiffer_next_ifd(struct tiffer *self) -{ - // All fields from any previous IFD need to be read first. - if (self->remaining_fields) - return false; - - uint32_t ifd_offset = 0; - if (!tiffer_u32(self, &ifd_offset)) - return false; - - // There is nothing more to read, this chain has terminated. - if (!ifd_offset) - return false; - - // Note that TIFF 6.0 requires there to be at least one entry, - // but there is no need for us to check it. - self->p = self->begin + ifd_offset; - return tiffer_u16(self, &self->remaining_fields); -} - -/// Initialize a derived TIFF reader for a subIFD at the given location. -static bool -tiffer_subifd(struct tiffer *self, uint32_t offset, struct tiffer *subreader) -{ - *subreader = *self; - subreader->p = subreader->begin + offset; - return tiffer_u16(subreader, &subreader->remaining_fields); -} - -enum tiffer_type { - BYTE = 1, ASCII, SHORT, LONG, RATIONAL, - SBYTE, UNDEFINED, SSHORT, SLONG, SRATIONAL, FLOAT, DOUBLE, - IFD // This last type from TIFF Technical Note 1 isn't really used much. -}; - -static size_t -tiffer_value_size(enum tiffer_type type) -{ - switch (type) { - case BYTE: - case SBYTE: - case ASCII: - case UNDEFINED: - return 1; - case SHORT: - case SSHORT: - return 2; - case LONG: - case SLONG: - case FLOAT: - case IFD: - return 4; - case RATIONAL: - case SRATIONAL: - case DOUBLE: - return 8; - default: - return 0; - } -} - -/// A lean iterator for values within entries. -struct tiffer_entry { - uint16_t tag; - enum tiffer_type type; - // For {S,}BYTE, ASCII, UNDEFINED, use these fields directly. - const uint8_t *p; - uint32_t remaining_count; -}; - -static bool -tiffer_next_value(struct tiffer_entry *entry) -{ - if (!entry->remaining_count) - return false; - - entry->p += tiffer_value_size(entry->type); - entry->remaining_count--; - return true; -} - -static bool -tiffer_integer( - const struct tiffer *self, const struct tiffer_entry *entry, int64_t *out) -{ - if (!entry->remaining_count) - return false; - - // Somewhat excessively lenient, intended for display. - // TIFF 6.0 only directly suggests that a reader is should accept - // any of BYTE/SHORT/LONG for unsigned integers. - switch (entry->type) { - case BYTE: - case ASCII: - case UNDEFINED: - *out = *entry->p; - return true; - case SBYTE: - *out = (int8_t) *entry->p; - return true; - case SHORT: - *out = self->un->u16(entry->p); - return true; - case SSHORT: - *out = (int16_t) self->un->u16(entry->p); - return true; - case LONG: - case IFD: - *out = self->un->u32(entry->p); - return true; - case SLONG: - *out = (int32_t) self->un->u32(entry->p); - return true; - default: - return false; - } -} - -static bool -tiffer_rational(const struct tiffer *self, const struct tiffer_entry *entry, - int64_t *numerator, int64_t *denominator) -{ - if (!entry->remaining_count) - return false; - - // Somewhat excessively lenient, intended for display. - switch (entry->type) { - case RATIONAL: - *numerator = self->un->u32(entry->p); - *denominator = self->un->u32(entry->p + 4); - return true; - case SRATIONAL: - *numerator = (int32_t) self->un->u32(entry->p); - *denominator = (int32_t) self->un->u32(entry->p + 4); - return true; - default: - if (tiffer_integer(self, entry, numerator)) { - *denominator = 1; - return true; - } - return false; - } -} - -static bool -tiffer_real( - const struct tiffer *self, const struct tiffer_entry *entry, double *out) -{ - if (!entry->remaining_count) - return false; - - // Somewhat excessively lenient, intended for display. - // Assuming the host architecture uses IEEE 754. - switch (entry->type) { - int64_t numerator, denominator; - case FLOAT: - *out = *(float *) entry->p; - return true; - case DOUBLE: - *out = *(double *) entry->p; - return true; - default: - if (tiffer_rational(self, entry, &numerator, &denominator)) { - *out = (double) numerator / denominator; - return true; - } - return false; - } -} - -static bool -tiffer_next_entry(struct tiffer *self, struct tiffer_entry *entry) -{ - if (!self->remaining_fields) - return false; - - uint16_t type = entry->type = 0xFFFF; - if (!tiffer_u16(self, &entry->tag) || !tiffer_u16(self, &type) || - !tiffer_u32(self, &entry->remaining_count)) - return false; - - // Short values may and will be inlined, rather than pointed to. - size_t values_size = tiffer_value_size(type) * entry->remaining_count; - uint32_t offset = 0; - if (values_size <= sizeof offset) { - entry->p = self->p; - self->p += sizeof offset; - } else if (tiffer_u32(self, &offset)) { - entry->p = self->begin + offset; - } else { - return false; - } - - // All entries are pre-checked not to overflow. - if (entry->p + values_size > self->end) - return false; - - // Setting it at the end may provide an indication while debugging. - entry->type = type; - self->remaining_fields--; - return true; -} - -// --- TIFF/Exif tags ---------------------------------------------------------- - -struct tiff_value { - const char *name; - uint16_t value; -}; - -struct tiff_entry { - const char *name; - uint16_t tag; - struct tiff_value *values; -}; +// --- TIFF/Exif --------------------------------------------------------------- #include "tiff-tables.h" +#include "tiffer.h" // TODO(p): Consider if these can't be inlined into `tiff_entries`. static struct { @@ -374,6 +38,27 @@ static struct { {} }; +// --- Utilities --------------------------------------------------------------- + +#define u64be tiffer_u64be +#define u32be tiffer_u32be +#define u16be tiffer_u16be +#define u64le tiffer_u64le +#define u32le tiffer_u32le +#define u16le tiffer_u16le + +static char * +binhex(const uint8_t *data, size_t len) +{ + static const char *alphabet = "0123456789abcdef"; + char *buf = calloc(1, len * 2 + 1), *p = buf; + for (size_t i = 0; i < len; i++) { + *p++ = alphabet[data[i] >> 4]; + *p++ = alphabet[data[i] & 0xF]; + } + return buf; +} + // --- Analysis ---------------------------------------------------------------- static jv @@ -399,29 +84,78 @@ add_error(jv o, const char *message) return jv_object_set(o, jv_string("error"), jv_string(message)); } +// Forward declaration. +static jv parse_jpeg(jv o, const uint8_t *p, size_t len); + // --- Exif -------------------------------------------------------------------- static jv parse_exif_ifd(struct tiffer *T, const struct tiff_entry *info); +static bool +parse_exif_subifds_entry(const struct tiffer *T, + const struct tiffer_entry *entry, struct tiffer *subT) +{ + int64_t offset = 0; + return tiffer_integer(T, entry, &offset) && + offset >= 0 && offset <= UINT32_MAX && tiffer_subifd(T, offset, subT); +} + static jv -parse_exif_subifds(struct tiffer *T, const struct tiffer_entry *entry, +parse_exif_subifds(const struct tiffer *T, struct tiffer_entry *entry, struct tiff_entry *info) { - int64_t offset = 0; struct tiffer subT = {}; - if (!tiffer_integer(T, entry, &offset) || - offset < 0 || offset > UINT32_MAX || !tiffer_subifd(T, offset, &subT)) + if (!parse_exif_subifds_entry(T, entry, &subT)) return jv_null(); - // The chain should correspond to the values in the entry - // (TIFF Technical Note 1), we are not going to verify it. - // Note that Nikon NEFs do not follow this rule. jv a = jv_array(); do a = jv_array_append(a, parse_exif_ifd(&subT, info)); while (tiffer_next_ifd(&subT)); + + // The chain should correspond to the values in the entry (see TIFF + // Technical Note 1: "the NextIFD value of Child #1 must point to Child #2, + // and so on"), but at least some Nikon NEFs do not follow this rule. + if (jv_array_length(jv_copy(a)) == 1) { + while (tiffer_next_value(entry) && + parse_exif_subifds_entry(T, entry, &subT)) + a = jv_array_append(a, parse_exif_ifd(&subT, info)); + } return a; } +// Implemented partially, out of curiosity--it is not particularly useful, +// because there is a ton more parsing to do here. +static bool +parse_exif_makernote(jv *v, const struct tiffer_entry *entry) +{ + if (!getenv("INFO_MAKERNOTE") || + entry->tag != Exif_MakerNote || entry->type != TIFFER_UNDEFINED) + return false; + + struct tiffer T = {}; + if (entry->remaining_count >= 16 && + !memcmp(entry->p, "Nikon\x00\x02", 7) && + tiffer_init(&T, entry->p + 10, entry->remaining_count - 10) && + tiffer_next_ifd(&T)) { + *v = parse_exif_ifd(&T, NULL); + return true; + } + if (entry->remaining_count >= 16 && + !memcmp(entry->p, "Apple iOS\x00\x00\x01MM", 14)) { + T.un = &tiffer_unbe; + T.begin = T.p = entry->p + 14; + T.end = entry->p + entry->remaining_count - 14; + T.remaining_fields = 0; + + struct tiffer subT = {}; + if (tiffer_subifd(&T, 0, &subT)) { + *v = parse_exif_ifd(&subT, NULL); + return true; + } + } + return false; +} + static jv parse_exif_ascii(struct tiffer_entry *entry) { @@ -472,11 +206,13 @@ parse_exif_extract_sole_array_element(jv a) } static jv -parse_exif_entry(jv o, struct tiffer *T, struct tiffer_entry *entry, +parse_exif_entry(jv o, const struct tiffer *T, struct tiffer_entry *entry, const struct tiff_entry *info) { + const struct tiff_entry *info_begin = info; + static struct tiff_entry empty[] = {{}}; if (!info) - info = (struct tiff_entry[]) {{}}; + info = empty; for (; info->name; info++) if (info->tag == entry->tag) @@ -491,13 +227,18 @@ parse_exif_entry(jv o, struct tiffer *T, struct tiffer_entry *entry, double real = 0; if (!entry->remaining_count) { v = jv_null(); - } else if (entry->type == IFD || subentries) { + } else if (entry->type == TIFFER_IFD || subentries) { v = parse_exif_subifds(T, entry, subentries); - } else if (entry->type == ASCII) { + } else if (entry->type == TIFFER_ASCII) { v = parse_exif_extract_sole_array_element(parse_exif_ascii(entry)); - } else if (entry->type == UNDEFINED && !info->values) { + } else if (info_begin == exif_entries && parse_exif_makernote(&v, entry)) { + // Already processed. + } else if (entry->type == TIFFER_UNDEFINED && !info->values) { // Several Exif entries of UNDEFINED type contain single-byte numbers. v = parse_exif_undefined(entry); + } else if (info_begin == tiff_entries && entry->tag == TIFF_XMP && + (entry->type == TIFFER_UNDEFINED || entry->type == TIFFER_BYTE)) { + v = jv_string_sized((const char *) entry->p, entry->remaining_count); } else if (tiffer_real(T, entry, &real)) { v = jv_array(); do v = jv_array_append(v, parse_exif_value(info->values, real)); @@ -513,10 +254,55 @@ parse_exif_entry(jv o, struct tiffer *T, struct tiffer_entry *entry, static jv parse_exif_ifd(struct tiffer *T, const struct tiff_entry *info) { + int64_t compression = 0, + jpeg = 0, jpeg_length = 0, strip_offsets = 0, strip_byte_counts = 0; + jv ifd = jv_object(); struct tiffer_entry entry = {}; - while (tiffer_next_entry(T, &entry)) + while (tiffer_next_entry(T, &entry)) { + switch (entry.tag) { + case TIFF_Compression: + tiffer_integer(T, &entry, &compression); + break; + case TIFF_JPEGInterchangeFormat: + tiffer_integer(T, &entry, &jpeg); + break; + case TIFF_JPEGInterchangeFormatLength: + tiffer_integer(T, &entry, &jpeg_length); + break; + case TIFF_StripOffsets: + tiffer_integer(T, &entry, &strip_offsets); + break; + case TIFF_StripByteCounts: + tiffer_integer(T, &entry, &strip_byte_counts); + break; + } + ifd = parse_exif_entry(ifd, T, &entry, info); + } + + // This is how Exif specifies it, which doesn't follow TIFF 6.0. + // Also support CR2 IFD1, which isn't tagged with compression at all. + if (info == tiff_entries && /* compression == TIFF_Compression_JPEG && */ + jpeg > 0 && jpeg_length > 0 && + jpeg + jpeg_length <= (T->end - T->begin)) { + ifd = jv_set(ifd, jv_string("JPEG image data"), + parse_jpeg( + jv_object(), T->begin + jpeg, jpeg_length)); + } + + // As specified by DRAFT TIFF Technical Note 2 + TIFFphotoshop.pdf. + // Theoretically, there may be more strips, but this is not expected. + // Also support CR2 IFD0, which is tagged with the "wrong" compression. + if (info == tiff_entries && + (compression == TIFF_Compression_JPEGDatastream || + compression == TIFF_Compression_JPEG) && + strip_offsets > 0 && strip_byte_counts > 0 && + strip_offsets + strip_byte_counts <= (T->end - T->begin)) { + ifd = jv_set(ifd, jv_string("JPEG image data"), + parse_jpeg( + jv_object(), T->begin + strip_offsets, strip_byte_counts)); + } return ifd; } @@ -531,6 +317,25 @@ parse_exif(jv o, const uint8_t *p, size_t len) return o; } +static bool +detect_tiff(const uint8_t *p, size_t len) +{ + return tiffer_init(&(struct tiffer) {}, p, len); +} + +// TODO(p): Photoshop data and ICC profiles also have their tag in TIFF, +// they're not currently processed. +static jv +parse_tiff(jv o, const uint8_t *p, size_t len) +{ + struct tiffer T = {}; + if (!tiffer_init(&T, p, len)) + return add_warning(o, "invalid TIFF"); + while (tiffer_next_ifd(&T)) + o = add_to_subarray(o, "TIFF", parse_exif_ifd(&T, tiff_entries)); + return o; +} + // --- Photoshop Image Resources ----------------------------------------------- // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 + 3.1.3 // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ @@ -885,3 +690,513 @@ parse_icc(jv o, const uint8_t *profile, size_t profile_len) // The description is required, so this should be unreachable. return jv_set(o, jv_string("ICC"), jv_bool(true)); } + +// --- Multi-Picture Format ---------------------------------------------------- + +static uint32_t +parse_mpf_mpentry(jv *a, const uint8_t *p, const struct tiffer *T) +{ + uint32_t attrs = T->un->u32(p); + uint32_t offset = T->un->u32(p + 8); + + uint32_t type_number = attrs & 0xFFFFFF; + jv type = jv_number(type_number); + switch (type_number) { + break; case 0x030000: type = jv_string("Baseline MP Primary Image"); + break; case 0x010001: type = jv_string("Large Thumbnail - VGA"); + break; case 0x010002: type = jv_string("Large Thumbnail - Full HD"); + break; case 0x020001: type = jv_string("Multi-Frame Image Panorama"); + break; case 0x020002: type = jv_string("Multi-Frame Image Disparity"); + break; case 0x020003: type = jv_string("Multi-Frame Image Multi-Angle"); + break; case 0x000000: type = jv_string("Undefined"); + } + + uint32_t format_number = (attrs >> 24) & 0x7; + jv format = jv_number(format_number); + if (format_number == 0) + format = jv_string("JPEG"); + + *a = jv_array_append(*a, JV_OBJECT( + jv_string("Individual Image Attribute"), JV_OBJECT( + jv_string("Dependent Parent Image"), jv_bool((attrs >> 31) & 1), + jv_string("Dependent Child Image"), jv_bool((attrs >> 30) & 1), + jv_string("Representative Image"), jv_bool((attrs >> 29) & 1), + jv_string("Reserved"), jv_number((attrs >> 27) & 0x3), + jv_string("Image Data Format"), format, + jv_string("MP Type Code"), type + ), + jv_string("Individual Image Size"), + jv_number(T->un->u32(p + 4)), + jv_string("Individual Image Data Offset"), + jv_number(offset), + jv_string("Dependent Image 1 Entry Number"), + jv_number(T->un->u16(p + 12)), + jv_string("Dependent Image 2 Entry Number"), + jv_number(T->un->u16(p + 14)) + )); + + // Don't report non-JPEGs, even though they're unlikely. + return format_number == 0 ? offset : 0; +} + +static jv +parse_mpf_index_entry(jv o, uint32_t **offsets, const struct tiffer *T, + struct tiffer_entry *entry) +{ + // 5.2.3.3. MP Entry + if (entry->tag != MPF_MPEntry || entry->type != TIFFER_UNDEFINED || + entry->remaining_count % 16) { + return parse_exif_entry(o, T, entry, mpf_entries); + } + + uint32_t count = entry->remaining_count / 16; + jv a = jv_array_sized(count); + uint32_t *out = *offsets = calloc(sizeof *out, count + 1); + for (uint32_t i = 0; i < count; i++) { + // 5.2.3.3.3. Individual Image Data Offset + uint32_t offset = parse_mpf_mpentry(&a, entry->p + i * 16, T); + if (offset) + *out++ = offset; + } + return jv_set(o, jv_string("MP Entry"), a); +} + +static jv +parse_mpf_index_ifd(uint32_t **offsets, struct tiffer *T) +{ + jv ifd = jv_object(); + struct tiffer_entry entry = {}; + while (tiffer_next_entry(T, &entry)) + ifd = parse_mpf_index_entry(ifd, offsets, T, &entry); + return ifd; +} + +static jv +parse_mpf(jv o, const uint8_t ***individuals, const uint8_t *p, size_t len, + const uint8_t *end) +{ + struct tiffer T; + if (!tiffer_init(&T, p, len) || !tiffer_next_ifd(&T)) + return add_warning(o, "invalid MPF segment"); + + // First image: IFD0 is Index IFD, any IFD1 is Attribute IFD. + // Other images: IFD0 is Attribute IFD, there is no Index IFD. + uint32_t *offsets = NULL; + if (!*individuals) { + o = add_to_subarray(o, "MPF", parse_mpf_index_ifd(&offsets, &T)); + if (!tiffer_next_ifd(&T)) + goto out; + } + + // This isn't optimal, but it will do. + o = add_to_subarray(o, "MPF", parse_exif_ifd(&T, mpf_entries)); + +out: + if (offsets) { + size_t count = 0; + for (const uint32_t *i = offsets; *i; i++) + count++; + + free(*individuals); + const uint8_t **out = *individuals = calloc(sizeof *out, count + 1); + for (const uint32_t *i = offsets; *i; i++) { + if (*i > end - p) + o = add_warning(o, "MPF offset points past available data"); + else + *out++ = p + *i; + } + + free(offsets); + } + return o; +} + +// --- JPEG -------------------------------------------------------------------- +// Because the JPEG file format is simple, just do it manually. +// See: https://www.w3.org/Graphics/JPEG/itu-t81.pdf + +enum { + TEM = 0x01, + SOF0 = 0xC0, SOF1, SOF2, SOF3, + DHT = 0xC4, + SOF5, SOF6, SOF7, + JPG = 0xC8, + SOF9, SOF10, SOF11, + DAC = 0xCC, + SOF13, SOF14, SOF15, + + RST0 = 0xD0, RST1, RST2, RST3, RST4, RST5, RST6, RST7, + + SOI = 0xD8, + EOI = 0xD9, + SOS = 0xDA, + DQT = 0xDB, + DNL = 0xDC, + DRI = 0xDD, + DHP = 0xDE, + EXP = 0xDF, + + APP0 = 0xE0, APP1, APP2, APP3, APP4, APP5, APP6, APP7, + APP8, APP9, APP10, APP11, APP12, APP13, APP14, APP15, + + JPG0 = 0xF0, JPG1, JPG2, JPG3, JPG4, JPG5, JPG6, JPG7, + JPG8, JPG9, JPG10, JPG11, JPG12, JPG13, + + COM = 0xFE +}; + +// The rest is "RES (Reserved)", except for 0xFF (filler) and 0x00 (invalid). +static const char *marker_ids[0xFF] = { + [TEM] = "TEM", + [SOF0] = "SOF0", [SOF1] = "SOF1", [SOF2] = "SOF2", [SOF3] = "SOF3", + [DHT] = "DHT", [SOF5] = "SOF5", [SOF6] = "SOF6", [SOF7] = "SOF7", + [JPG] = "JPG", [SOF9] = "SOF9", [SOF10] = "SOF10", [SOF11] = "SOF11", + [DAC] = "DAC", [SOF13] = "SOF13", [SOF14] = "SOF14", [SOF15] = "SOF15", + [RST0] = "RST0", [RST1] = "RST1", [RST2] = "RST2", [RST3] = "RST3", + [RST4] = "RST4", [RST5] = "RST5", [RST6] = "RST6", [RST7] = "RST7", + [SOI] = "SOI", [EOI] = "EOI", [SOS] = "SOS", [DQT] = "DQT", + [DNL] = "DNL", [DRI] = "DRI", [DHP] = "DHP", [EXP] = "EXP", + [APP0] = "APP0", [APP1] = "APP1", [APP2] = "APP2", [APP3] = "APP3", + [APP4] = "APP4", [APP5] = "APP5", [APP6] = "APP6", [APP7] = "APP7", + [APP8] = "APP8", [APP9] = "APP9", [APP10] = "APP10", [APP11] = "APP11", + [APP12] = "APP12", [APP13] = "APP13", [APP14] = "APP14", [APP15] = "APP15", + [JPG0] = "JPG0", [JPG1] = "JPG1", [JPG2] = "JPG2", [JPG3] = "JPG3", + [JPG4] = "JPG4", [JPG5] = "JPG5", [JPG6] = "JPG6", [JPG7] = "JPG7", + [JPG8] = "JPG8", [JPG9] = "JPG9", [JPG10] = "JPG10", [JPG11] = "JPG11", + [JPG12] = "JPG12", [JPG13] = "JPG13", [COM] = "COM" +}; + +// The rest is "RES (Reserved)", except for 0xFF (filler) and 0x00 (invalid). +static const char *marker_descriptions[0xFF] = { + [TEM] = "For temporary private use in arithmetic coding", + [SOF0] = "Baseline DCT", + [SOF1] = "Extended sequential DCT", + [SOF2] = "Progressive DCT", + [SOF3] = "Lossless (sequential)", + [DHT] = "Define Huffman table(s)", + [SOF5] = "Differential sequential DCT", + [SOF6] = "Differential progressive DCT", + [SOF7] = "Differential lossless (sequential)", + [JPG] = "Reserved for JPEG extensions", + [SOF9] = "Extended sequential DCT", + [SOF10] = "Progressive DCT", + [SOF11] = "Lossless (sequential)", + [DAC] = "Define arithmetic coding conditioning(s)", + [SOF13] = "Differential sequential DCT", + [SOF14] = "Differential progressive DCT", + [SOF15] = "Differential lossless (sequential)", + [RST0] = "Restart with module 8 count 0", + [RST1] = "Restart with module 8 count 1", + [RST2] = "Restart with module 8 count 2", + [RST3] = "Restart with module 8 count 3", + [RST4] = "Restart with module 8 count 4", + [RST5] = "Restart with module 8 count 5", + [RST6] = "Restart with module 8 count 6", + [RST7] = "Restart with module 8 count 7", + [SOI] = "Start of image", + [EOI] = "End of image", + [SOS] = "Start of scan", + [DQT] = "Define quantization table(s)", + [DNL] = "Define number of lines", + [DRI] = "Define restart interval", + [DHP] = "Define hierarchical progression", + [EXP] = "Expand reference component(s)", + [APP0] = "Reserved for application segments, 0", + [APP1] = "Reserved for application segments, 1", + [APP2] = "Reserved for application segments, 2", + [APP3] = "Reserved for application segments, 3", + [APP4] = "Reserved for application segments, 4", + [APP5] = "Reserved for application segments, 5", + [APP6] = "Reserved for application segments, 6", + [APP7] = "Reserved for application segments, 7", + [APP8] = "Reserved for application segments, 8", + [APP9] = "Reserved for application segments, 9", + [APP10] = "Reserved for application segments, 10", + [APP11] = "Reserved for application segments, 11", + [APP12] = "Reserved for application segments, 12", + [APP13] = "Reserved for application segments, 13", + [APP14] = "Reserved for application segments, 14", + [APP15] = "Reserved for application segments, 15", + [JPG0] = "Reserved for JPEG extensions, 0", + [JPG1] = "Reserved for JPEG extensions, 1", + [JPG2] = "Reserved for JPEG extensions, 2", + [JPG3] = "Reserved for JPEG extensions, 3", + [JPG4] = "Reserved for JPEG extensions, 4", + [JPG5] = "Reserved for JPEG extensions, 5", + [JPG6] = "Reserved for JPEG extensions, 6", + [JPG7] = "Reserved for JPEG extensions, 7", + [JPG8] = "Reserved for JPEG extensions, 8", + [JPG9] = "Reserved for JPEG extensions, 9", + [JPG10] = "Reserved for JPEG extensions, 10", + [JPG11] = "Reserved for JPEG extensions, 11", + [JPG12] = "Reserved for JPEG extensions, 12", + [JPG13] = "Reserved for JPEG extensions, 13", + [COM] = "Comment", +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct data { + bool ended; + uint8_t *exif, *icc, *psir; + size_t exif_len, icc_len, psir_len; + int icc_sequence, icc_done; + const uint8_t **mpf_individuals, **mpf_next; +}; + +static void +parse_append(uint8_t **buffer, size_t *buffer_len, const uint8_t *p, size_t len) +{ + size_t buffer_longer = *buffer_len + len; + *buffer = realloc(*buffer, buffer_longer); + memcpy(*buffer + *buffer_len, p, len); + *buffer_len = buffer_longer; +} + +static const uint8_t * +parse_marker(uint8_t marker, const uint8_t *p, const uint8_t *end, + struct data *data, jv *o) +{ + // Suspected: MJPEG? Undetected format recursion, e.g., thumbnails? + // Found: Random metadata! Multi-Picture Format! + if ((data->ended = marker == EOI)) { + // TODO(p): Handle Exifs independently--flush the last one. + if ((data->mpf_next || (data->mpf_next = data->mpf_individuals)) && + *data->mpf_next) + return *data->mpf_next++; + if (p != end) + *o = add_warning(*o, "trailing data"); + } + + // These markers stand alone, not starting a marker segment. + switch (marker) { + case RST0: + case RST1: + case RST2: + case RST3: + case RST4: + case RST5: + case RST6: + case RST7: + *o = add_warning(*o, "unexpected restart marker"); + // Fall-through + case SOI: + case EOI: + case TEM: + return p; + } + + uint16_t length = p[0] << 8 | p[1]; + const uint8_t *payload = p + 2; + if ((p += length) > end) { + *o = add_error(*o, "runaway marker segment"); + return NULL; + } + + switch (marker) { + case SOF0: + case SOF1: + case SOF2: + case SOF3: + case SOF5: + case SOF6: + case SOF7: + case SOF9: + case SOF10: + case SOF11: + case SOF13: + case SOF14: + case SOF15: + case DHP: // B.2.2 and B.3.2. + // As per B.2.5, Y can be zero, then there needs to be a DNL segment. + *o = add_to_subarray(*o, "info", JV_OBJECT( + jv_string("type"), jv_string(marker_descriptions[marker]), + jv_string("bits"), jv_number(payload[0]), + jv_string("height"), jv_number(payload[1] << 8 | payload[2]), + jv_string("width"), jv_number(payload[3] << 8 | payload[4]), + jv_string("components"), jv_number(payload[5]) + )); + return p; + } + + // See B.1.1.5, we can brute-force our way through the entropy-coded data. + if (marker == SOS) { + while (p + 2 <= end && (p[0] != 0xFF || p[1] < 0xC0 || p[1] > 0xFE || + (p[1] >= RST0 && p[1] <= RST7))) + p++; + return p; + } + + // "The interpretation is left to the application." + if (marker == COM) { + int superascii = 0; + char *buf = calloc(3, p - payload), *bufp = buf; + for (const uint8_t *q = payload; q < p; q++) { + if (*q < 128) { + *bufp++ = *q; + } else { + superascii++; + *bufp++ = 0xC0 | (*q >> 6); + *bufp++ = 0x80 | (*q & 0x3F); + } + } + *bufp++ = 0; + *o = add_to_subarray(*o, "comments", jv_string(buf)); + free(buf); + + if (superascii) + *o = add_warning(*o, "super-ASCII comments"); + } + + // These mostly contain an ASCII string header, following JPEG FIF: + // + // "Application-specific APP0 marker segments are identified + // by a zero terminated string which identifies the application + // (not 'JFIF' or 'JFXX')." + if (marker >= APP0 && marker <= APP15) { + const uint8_t *nul = memchr(payload, 0, p - payload); + int unprintable = !nul; + if (nul) { + for (const uint8_t *q = payload; q < nul; q++) + unprintable += *q < 32 || *q >= 127; + } + *o = add_to_subarray(*o, "apps", + unprintable ? jv_null() : jv_string((const char *) payload)); + } + + // CIPA DC-007-2021 (Multi-Picture Format) 5.2 + // https://www.cipa.jp/e/std/std-sec.html + if (marker == APP2 && p - payload >= 8 && !memcmp(payload, "MPF\0", 4)) { + payload += 4; + *o = parse_mpf(*o, &data->mpf_individuals, payload, p - payload, end); + } + + // CIPA DC-006 (Stereo Still Image Format for Digital Cameras) + // TODO(p): Handle by properly skipping trailing data (use Stim offsets). + + // https://www.w3.org/Graphics/JPEG/jfif3.pdf + if (marker == APP0 && p - payload >= 14 && !memcmp(payload, "JFIF\0", 5)) { + payload += 5; + + jv units = jv_number(payload[2]); + switch (payload[2]) { + break; case 0: units = jv_null(); + break; case 1: units = jv_string("DPI"); + break; case 2: units = jv_string("dots per cm"); + } + + // The rest is picture data. + *o = add_to_subarray(*o, "JFIF", JV_OBJECT( + jv_string("version"), jv_number(payload[0] * 100 + payload[1]), + jv_string("units"), units, + jv_string("density-x"), jv_number(payload[3] << 8 | payload[4]), + jv_string("density-y"), jv_number(payload[5] << 8 | payload[6]), + jv_string("thumbnail-w"), jv_number(payload[7]), + jv_string("thumbnail-h"), jv_number(payload[8]) + )); + } + if (marker == APP0 && p - payload >= 6 && !memcmp(payload, "JFXX\0", 5)) { + payload += 5; + + jv extension = jv_number(payload[0]); + switch (payload[0]) { + break; case 0x10: extension = jv_string("JPEG thumbnail"); + break; case 0x11: extension = jv_string("Paletted thumbnail"); + break; case 0x13: extension = jv_string("RGB thumbnail"); + } + + // The rest is picture data. + *o = add_to_subarray(*o, "JFXX", + JV_OBJECT(jv_string("extension"), extension)); + } + + // https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf 4.7.2 + // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 + if (marker == APP1 && p - payload >= 6 && !memcmp(payload, "Exif\0", 5)) { + payload += 6; + if (payload[-1] != 0) + *o = add_warning(*o, "weirdly padded Exif header"); + if (data->exif) + *o = add_warning(*o, "multiple Exif segments"); + parse_append(&data->exif, &data->exif_len, payload, p - payload); + } + + // https://www.color.org/specification/ICC1v43_2010-12.pdf B.4 + if (marker == APP2 && p - payload >= 14 && + !memcmp(payload, "ICC_PROFILE\0", 12) && !data->icc_done && + payload[12] == ++data->icc_sequence && payload[13] >= payload[12]) { + payload += 14; + parse_append(&data->icc, &data->icc_len, payload, p - payload); + data->icc_done = payload[-1] == data->icc_sequence; + } + + // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 + 3.1.3 + // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ + if (marker == APP13 && p - payload >= 14 && + !memcmp(payload, "Photoshop 3.0\0", 14)) { + payload += 14; + parse_append(&data->psir, &data->psir_len, payload, p - payload); + } + + // TODO(p): Extract all XMP segments. + return p; +} + +static bool +detect_jpeg(const uint8_t *p, size_t len) +{ + return len >= 2 && p[0] == 0xff && p[1] == SOI; +} + +static jv +parse_jpeg(jv o, const uint8_t *p, size_t len) +{ + struct data data = {}; + const uint8_t *end = p + len; + jv markers = jv_array(); + while (p) { + // This is an expectable condition, use a simple warning. + if (p + 2 > end) { + if (!data.ended) + o = add_warning(o, "unexpected EOF"); + break; + } + if (*p++ != 0xFF || *p == 0) { + if (!data.ended) + o = add_error(o, "no marker found where one was expected"); + break; + } + + // Markers may be preceded by fill bytes. + if (*p == 0xFF) { + o = jv_object_set(o, jv_string("fillers"), jv_bool(true)); + continue; + } + + uint8_t marker = *p++; + markers = jv_array_append(markers, + jv_string(marker_ids[marker] ? marker_ids[marker] : "RES")); + p = parse_marker(marker, p, end, &data, &o); + } + + if (data.exif) { + // TODO(p): Probably extend it until the end of the JPEG, + // seeing as, e.g., thumbnail data can overflow into follow-up segments. + o = parse_exif(o, data.exif, data.exif_len); + free(data.exif); + } + if (data.icc) { + if (data.icc_done) + o = parse_icc(o, data.icc, data.icc_len); + else + o = add_warning(o, "bad ICC profile sequence"); + free(data.icc); + } + if (data.psir) { + o = parse_psir(o, data.psir, data.psir_len); + free(data.psir); + } + + free(data.mpf_individuals); + return jv_set(o, jv_string("markers"), markers); +} diff --git a/tools/jpeginfo.c b/tools/jpeginfo.c deleted file mode 100644 index 6a0994b..0000000 --- a/tools/jpeginfo.c +++ /dev/null @@ -1,610 +0,0 @@ -// -// jpeginfo.c: acquire information about JPEG files in JSON format -// -// Copyright (c) 2021, 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 "info.h" - -#include <jv.h> - -#include <errno.h> -#include <stdbool.h> -#include <stdio.h> -#include <stdlib.h> -#include <string.h> - -// --- Multi-Picture Format ---------------------------------------------------- - -enum { - MPF_MPFVersion = 45056, - MPF_NumberOfImages = 45057, - MPF_MPEntry = 45058, - MPF_ImageUIDList = 45059, - MPF_TotalFrames = 45060, - - MPF_MPIndividualNum = 45313, - MPF_PanOrientation = 45569, - MPF_PanOverlap_H = 45570, - MPF_PanOverlap_V = 45571, - MPF_BaseViewpointNum = 45572, - MPF_ConvergenceAngle = 45573, - MPF_BaselineLength = 45574, - MPF_VerticalDivergence = 45575, - MPF_AxisDistance_X = 45576, - MPF_AxisDistance_Y = 45577, - MPF_AxisDistance_Z = 45578, - MPF_YawAngle = 45579, - MPF_PitchAngle = 45580, - MPF_RollAngle = 45581 -}; - -static struct tiff_entry mpf_entries[] = { - {"MP Format Version Number", MPF_MPFVersion, NULL}, - {"Number of Images", MPF_NumberOfImages, NULL}, - {"MP Entry", MPF_MPEntry, NULL}, - {"Individual Image Unique ID List", MPF_ImageUIDList, NULL}, - {"Total Number of Captured Frames", MPF_TotalFrames, NULL}, - - {"MP Individual Image Number", MPF_MPIndividualNum, NULL}, - {"Panorama Scanning Orientation", MPF_PanOrientation, NULL}, - {"Panorama Horizontal Overlap", MPF_PanOverlap_H, NULL}, - {"Panorama Vertical Overlap", MPF_PanOverlap_V, NULL}, - {"Base Viewpoint Number", MPF_BaseViewpointNum, NULL}, - {"Convergence Angle", MPF_ConvergenceAngle, NULL}, - {"Baseline Length", MPF_BaselineLength, NULL}, - {"Divergence Angle", MPF_VerticalDivergence, NULL}, - {"Horizontal Axis Distance", MPF_AxisDistance_X, NULL}, - {"Vertical Axis Distance", MPF_AxisDistance_Y, NULL}, - {"Collimation Axis Distance", MPF_AxisDistance_Z, NULL}, - {"Yaw Angle", MPF_YawAngle, NULL}, - {"Pitch Angle", MPF_PitchAngle, NULL}, - {"Roll Angle", MPF_RollAngle, NULL}, - {} -}; - -static uint32_t -parse_mpf_mpentry(jv *a, const uint8_t *p, struct tiffer *T) -{ - uint32_t attrs = T->un->u32(p); - uint32_t offset = T->un->u32(p + 8); - - uint32_t type_number = attrs & 0xFFFFFF; - jv type = jv_number(type_number); - switch (type_number) { - break; case 0x030000: type = jv_string("Baseline MP Primary Image"); - break; case 0x010001: type = jv_string("Large Thumbnail - VGA"); - break; case 0x010002: type = jv_string("Large Thumbnail - Full HD"); - break; case 0x020001: type = jv_string("Multi-Frame Image Panorama"); - break; case 0x020002: type = jv_string("Multi-Frame Image Disparity"); - break; case 0x020003: type = jv_string("Multi-Frame Image Multi-Angle"); - break; case 0x000000: type = jv_string("Undefined"); - } - - uint32_t format_number = (attrs >> 24) & 0x7; - jv format = jv_number(format_number); - if (format_number == 0) - format = jv_string("JPEG"); - - *a = jv_array_append(*a, JV_OBJECT( - jv_string("Individual Image Attribute"), JV_OBJECT( - jv_string("Dependent Parent Image"), jv_bool((attrs >> 31) & 1), - jv_string("Dependent Child Image"), jv_bool((attrs >> 30) & 1), - jv_string("Representative Image"), jv_bool((attrs >> 29) & 1), - jv_string("Reserved"), jv_number((attrs >> 27) & 0x3), - jv_string("Image Data Format"), format, - jv_string("MP Type Code"), type - ), - jv_string("Individual Image Size"), - jv_number(T->un->u32(p + 4)), - jv_string("Individual Image Data Offset"), - jv_number(offset), - jv_string("Dependent Image 1 Entry Number"), - jv_number(T->un->u16(p + 12)), - jv_string("Dependent Image 2 Entry Number"), - jv_number(T->un->u16(p + 14)) - )); - - // Don't report non-JPEGs, even though they're unlikely. - return format_number == 0 ? offset : 0; -} - -static jv -parse_mpf_index_entry(jv o, const uint8_t ***offsets, struct tiffer *T, - struct tiffer_entry *entry) -{ - // 5.2.3.3. MP Entry - if (entry->tag != MPF_MPEntry || entry->type != UNDEFINED || - entry->remaining_count % 16) { - return parse_exif_entry(o, T, entry, mpf_entries); - } - - uint32_t count = entry->remaining_count / 16; - jv a = jv_array_sized(count); - const uint8_t **out = *offsets = calloc(sizeof *out, count + 1); - for (uint32_t i = 0; i < count; i++) { - uint32_t offset = parse_mpf_mpentry(&a, entry->p + i * 16, T); - if (offset) - *out++ = T->begin + offset; - } - return jv_set(o, jv_string("MP Entry"), a); -} - -static jv -parse_mpf_index_ifd(const uint8_t ***offsets, struct tiffer *T) -{ - jv ifd = jv_object(); - struct tiffer_entry entry = {}; - while (tiffer_next_entry(T, &entry)) - ifd = parse_mpf_index_entry(ifd, offsets, T, &entry); - return ifd; -} - -static jv -parse_mpf(jv o, const uint8_t ***offsets, const uint8_t *p, size_t len) -{ - struct tiffer T; - if (!tiffer_init(&T, p, len) || !tiffer_next_ifd(&T)) - return add_warning(o, "invalid MPF segment"); - - // First image: IFD0 is Index IFD, any IFD1 is Attribute IFD. - // Other images: IFD0 is Attribute IFD, there is no Index IFD. - if (!*offsets) { - o = add_to_subarray(o, "MPF", parse_mpf_index_ifd(offsets, &T)); - if (!tiffer_next_ifd(&T)) - return o; - } - - // This isn't optimal, but it will do. - return add_to_subarray(o, "MPF", parse_exif_ifd(&T, mpf_entries)); -} - -// --- JPEG -------------------------------------------------------------------- -// Because the JPEG file format is simple, just do it manually. -// See: https://www.w3.org/Graphics/JPEG/itu-t81.pdf - -enum { - TEM = 0x01, - SOF0 = 0xC0, SOF1, SOF2, SOF3, - DHT = 0xC4, - SOF5, SOF6, SOF7, - JPG = 0xC8, - SOF9, SOF10, SOF11, - DAC = 0xCC, - SOF13, SOF14, SOF15, - - RST0 = 0xD0, RST1, RST2, RST3, RST4, RST5, RST6, RST7, - - SOI = 0xD8, - EOI = 0xD9, - SOS = 0xDA, - DQT = 0xDB, - DNL = 0xDC, - DRI = 0xDD, - DHP = 0xDE, - EXP = 0xDF, - - APP0 = 0xE0, APP1, APP2, APP3, APP4, APP5, APP6, APP7, - APP8, APP9, APP10, APP11, APP12, APP13, APP14, APP15, - - JPG0 = 0xF0, JPG1, JPG2, JPG3, JPG4, JPG5, JPG6, JPG7, - JPG8, JPG9, JPG10, JPG11, JPG12, JPG13, - - COM = 0xFE -}; - -// The rest is "RES (Reserved)", except for 0xFF (filler) and 0x00 (invalid). -static const char *marker_ids[0xFF] = { - [TEM] = "TEM", - [SOF0] = "SOF0", [SOF1] = "SOF1", [SOF2] = "SOF2", [SOF3] = "SOF3", - [DHT] = "DHT", [SOF5] = "SOF5", [SOF6] = "SOF6", [SOF7] = "SOF7", - [JPG] = "JPG", [SOF9] = "SOF9", [SOF10] = "SOF10", [SOF11] = "SOF11", - [DAC] = "DAC", [SOF13] = "SOF13", [SOF14] = "SOF14", [SOF15] = "SOF15", - [RST0] = "RST0", [RST1] = "RST1", [RST2] = "RST2", [RST3] = "RST3", - [RST4] = "RST4", [RST5] = "RST5", [RST6] = "RST6", [RST7] = "RST7", - [SOI] = "SOI", [EOI] = "EOI", [SOS] = "SOS", [DQT] = "DQT", - [DNL] = "DNL", [DRI] = "DRI", [DHP] = "DHP", [EXP] = "EXP", - [APP0] = "APP0", [APP1] = "APP1", [APP2] = "APP2", [APP3] = "APP3", - [APP4] = "APP4", [APP5] = "APP5", [APP6] = "APP6", [APP7] = "APP7", - [APP8] = "APP8", [APP9] = "APP9", [APP10] = "APP10", [APP11] = "APP11", - [APP12] = "APP12", [APP13] = "APP13", [APP14] = "APP14", [APP15] = "APP15", - [JPG0] = "JPG0", [JPG1] = "JPG1", [JPG2] = "JPG2", [JPG3] = "JPG3", - [JPG4] = "JPG4", [JPG5] = "JPG5", [JPG6] = "JPG6", [JPG7] = "JPG7", - [JPG8] = "JPG8", [JPG9] = "JPG9", [JPG10] = "JPG10", [JPG11] = "JPG11", - [JPG12] = "JPG12", [JPG13] = "JPG13", [COM] = "COM" -}; - -// The rest is "RES (Reserved)", except for 0xFF (filler) and 0x00 (invalid). -static const char *marker_descriptions[0xFF] = { - [TEM] = "For temporary private use in arithmetic coding", - [SOF0] = "Baseline DCT", - [SOF1] = "Extended sequential DCT", - [SOF2] = "Progressive DCT", - [SOF3] = "Lossless (sequential)", - [DHT] = "Define Huffman table(s)", - [SOF5] = "Differential sequential DCT", - [SOF6] = "Differential progressive DCT", - [SOF7] = "Differential lossless (sequential)", - [JPG] = "Reserved for JPEG extensions", - [SOF9] = "Extended sequential DCT", - [SOF10] = "Progressive DCT", - [SOF11] = "Lossless (sequential)", - [DAC] = "Define arithmetic coding conditioning(s)", - [SOF13] = "Differential sequential DCT", - [SOF14] = "Differential progressive DCT", - [SOF15] = "Differential lossless (sequential)", - [RST0] = "Restart with module 8 count 0", - [RST1] = "Restart with module 8 count 1", - [RST2] = "Restart with module 8 count 2", - [RST3] = "Restart with module 8 count 3", - [RST4] = "Restart with module 8 count 4", - [RST5] = "Restart with module 8 count 5", - [RST6] = "Restart with module 8 count 6", - [RST7] = "Restart with module 8 count 7", - [SOI] = "Start of image", - [EOI] = "End of image", - [SOS] = "Start of scan", - [DQT] = "Define quantization table(s)", - [DNL] = "Define number of lines", - [DRI] = "Define restart interval", - [DHP] = "Define hierarchical progression", - [EXP] = "Expand reference component(s)", - [APP0] = "Reserved for application segments, 0", - [APP1] = "Reserved for application segments, 1", - [APP2] = "Reserved for application segments, 2", - [APP3] = "Reserved for application segments, 3", - [APP4] = "Reserved for application segments, 4", - [APP5] = "Reserved for application segments, 5", - [APP6] = "Reserved for application segments, 6", - [APP7] = "Reserved for application segments, 7", - [APP8] = "Reserved for application segments, 8", - [APP9] = "Reserved for application segments, 9", - [APP10] = "Reserved for application segments, 10", - [APP11] = "Reserved for application segments, 11", - [APP12] = "Reserved for application segments, 12", - [APP13] = "Reserved for application segments, 13", - [APP14] = "Reserved for application segments, 14", - [APP15] = "Reserved for application segments, 15", - [JPG0] = "Reserved for JPEG extensions, 0", - [JPG1] = "Reserved for JPEG extensions, 1", - [JPG2] = "Reserved for JPEG extensions, 2", - [JPG3] = "Reserved for JPEG extensions, 3", - [JPG4] = "Reserved for JPEG extensions, 4", - [JPG5] = "Reserved for JPEG extensions, 5", - [JPG6] = "Reserved for JPEG extensions, 6", - [JPG7] = "Reserved for JPEG extensions, 7", - [JPG8] = "Reserved for JPEG extensions, 8", - [JPG9] = "Reserved for JPEG extensions, 9", - [JPG10] = "Reserved for JPEG extensions, 10", - [JPG11] = "Reserved for JPEG extensions, 11", - [JPG12] = "Reserved for JPEG extensions, 12", - [JPG13] = "Reserved for JPEG extensions, 13", - [COM] = "Comment", -}; - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -struct data { - bool ended; - uint8_t *exif, *icc, *psir; - size_t exif_len, icc_len, psir_len; - int icc_sequence, icc_done; - const uint8_t **mpf_offsets, **mpf_next; -}; - -static void -parse_append(uint8_t **buffer, size_t *buffer_len, const uint8_t *p, size_t len) -{ - size_t buffer_longer = *buffer_len + len; - *buffer = realloc(*buffer, buffer_longer); - memcpy(*buffer + *buffer_len, p, len); - *buffer_len = buffer_longer; -} - -static const uint8_t * -parse_marker(uint8_t marker, const uint8_t *p, const uint8_t *end, - struct data *data, jv *o) -{ - // Suspected: MJPEG? Undetected format recursion, e.g., thumbnails? - // Found: Random metadata! Multi-Picture Format! - if ((data->ended = marker == EOI)) { - // TODO(p): Handle Exifs independently--flush the last one. - if ((data->mpf_next || (data->mpf_next = data->mpf_offsets)) && - *data->mpf_next) - return *data->mpf_next++; - if (p != end) - *o = add_warning(*o, "trailing data"); - } - - // These markers stand alone, not starting a marker segment. - switch (marker) { - case RST0: - case RST1: - case RST2: - case RST3: - case RST4: - case RST5: - case RST6: - case RST7: - *o = add_warning(*o, "unexpected restart marker"); - // Fall-through - case SOI: - case EOI: - case TEM: - return p; - } - - uint16_t length = p[0] << 8 | p[1]; - const uint8_t *payload = p + 2; - if ((p += length) > end) { - *o = add_error(*o, "runaway marker segment"); - return NULL; - } - - switch (marker) { - case SOF0: - case SOF1: - case SOF2: - case SOF3: - case SOF5: - case SOF6: - case SOF7: - case SOF9: - case SOF10: - case SOF11: - case SOF13: - case SOF14: - case SOF15: - case DHP: // B.2.2 and B.3.2. - // As per B.2.5, Y can be zero, then there needs to be a DNL segment. - *o = add_to_subarray(*o, "info", JV_OBJECT( - jv_string("type"), jv_string(marker_descriptions[marker]), - jv_string("bits"), jv_number(payload[0]), - jv_string("height"), jv_number(payload[1] << 8 | payload[2]), - jv_string("width"), jv_number(payload[3] << 8 | payload[4]), - jv_string("components"), jv_number(payload[5]) - )); - return p; - } - - // See B.1.1.5, we can brute-force our way through the entropy-coded data. - if (marker == SOS) { - while (p + 2 <= end && (p[0] != 0xFF || p[1] < 0xC0 || p[1] > 0xFE || - (p[1] >= RST0 && p[1] <= RST7))) - p++; - return p; - } - - // "The interpretation is left to the application." - if (marker == COM) { - int superascii = 0; - char *buf = calloc(3, p - payload), *bufp = buf; - for (const uint8_t *q = payload; q < p; q++) { - if (*q < 128) { - *bufp++ = *q; - } else { - superascii++; - *bufp++ = 0xC0 | (*q >> 6); - *bufp++ = 0x80 | (*q & 0x3F); - } - } - *bufp++ = 0; - *o = add_to_subarray(*o, "comments", jv_string(buf)); - free(buf); - - if (superascii) - *o = add_warning(*o, "super-ASCII comments"); - } - - // These mostly contain an ASCII string header, following JPEG FIF: - // - // "Application-specific APP0 marker segments are identified - // by a zero terminated string which identifies the application - // (not 'JFIF' or 'JFXX')." - if (marker >= APP0 && marker <= APP15) { - const uint8_t *nul = memchr(payload, 0, p - payload); - int unprintable = !nul; - if (nul) { - for (const uint8_t *q = payload; q < nul; q++) - unprintable += *q < 32 || *q >= 127; - } - *o = add_to_subarray(*o, "apps", - unprintable ? jv_null() : jv_string((const char *) payload)); - } - - // CIPA DC-007 (Multi-Picture Format) 5.2 - // http://fileformats.archiveteam.org/wiki/Multi-Picture_Format - if (marker == APP2 && p - payload >= 8 && !memcmp(payload, "MPF\0", 4)) { - payload += 4; - *o = parse_mpf(*o, &data->mpf_offsets, payload, p - payload); - } - - // CIPA DC-006 (Stereo Still Image Format for Digital Cameras) - // TODO(p): Handle by properly skipping trailing data (use Stim offsets). - - // https://www.w3.org/Graphics/JPEG/jfif3.pdf - if (marker == APP0 && p - payload >= 14 && !memcmp(payload, "JFIF\0", 5)) { - payload += 5; - - jv units = jv_number(payload[2]); - switch (payload[2]) { - break; case 0: units = jv_null(); - break; case 1: units = jv_string("DPI"); - break; case 2: units = jv_string("dots per cm"); - } - - // The rest is picture data. - *o = add_to_subarray(*o, "JFIF", JV_OBJECT( - jv_string("version"), jv_number(payload[0] * 100 + payload[1]), - jv_string("units"), units, - jv_string("density-x"), jv_number(payload[3] << 8 | payload[4]), - jv_string("density-y"), jv_number(payload[5] << 8 | payload[6]), - jv_string("thumbnail-w"), jv_number(payload[7]), - jv_string("thumbnail-h"), jv_number(payload[8]) - )); - } - if (marker == APP0 && p - payload >= 6 && !memcmp(payload, "JFXX\0", 5)) { - payload += 5; - - jv extension = jv_number(payload[0]); - switch (payload[0]) { - break; case 0x10: extension = jv_string("JPEG thumbnail"); - break; case 0x11: extension = jv_string("Paletted thumbnail"); - break; case 0x13: extension = jv_string("RGB thumbnail"); - } - - // The rest is picture data. - *o = add_to_subarray(*o, "JFXX", - JV_OBJECT(jv_string("extension"), extension)); - } - - // https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf 4.7.2 - // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 - if (marker == APP1 && p - payload >= 6 && !memcmp(payload, "Exif\0", 5)) { - payload += 6; - if (payload[-1] != 0) - *o = add_warning(*o, "weirdly padded Exif header"); - if (data->exif) - *o = add_warning(*o, "multiple Exif segments"); - parse_append(&data->exif, &data->exif_len, payload, p - payload); - } - - // https://www.color.org/specification/ICC1v43_2010-12.pdf B.4 - if (marker == APP2 && p - payload >= 14 && - !memcmp(payload, "ICC_PROFILE\0", 12) && !data->icc_done && - payload[12] == ++data->icc_sequence && payload[13] >= payload[12]) { - payload += 14; - parse_append(&data->icc, &data->icc_len, payload, p - payload); - data->icc_done = payload[-1] == data->icc_sequence; - } - - // Adobe XMP Specification Part 3: Storage in Files, 2020/1, 1.1.3 + 3.1.3 - // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ - if (marker == APP13 && p - payload >= 14 && - !memcmp(payload, "Photoshop 3.0\0", 14)) { - payload += 14; - parse_append(&data->psir, &data->psir_len, payload, p - payload); - } - - // TODO(p): Extract all XMP segments. - return p; -} - -static jv -parse_jpeg(jv o, const uint8_t *p, size_t len) -{ - struct data data = {}; - const uint8_t *end = p + len; - jv markers = jv_array(); - while (p) { - // This is an expectable condition, use a simple warning. - if (p + 2 > end) { - if (!data.ended) - o = add_warning(o, "unexpected EOF"); - break; - } - if (*p++ != 0xFF || *p == 0) { - if (!data.ended) - o = add_error(o, "no marker found where one was expected"); - break; - } - - // Markers may be preceded by fill bytes. - if (*p == 0xFF) { - o = jv_object_set(o, jv_string("fillers"), jv_bool(true)); - continue; - } - - uint8_t marker = *p++; - markers = jv_array_append(markers, - jv_string(marker_ids[marker] ? marker_ids[marker] : "RES")); - p = parse_marker(marker, p, end, &data, &o); - } - - if (data.exif) { - o = parse_exif(o, data.exif, data.exif_len); - free(data.exif); - } - if (data.icc) { - if (data.icc_done) - o = parse_icc(o, data.icc, data.icc_len); - else - o = add_warning(o, "bad ICC profile sequence"); - free(data.icc); - } - if (data.psir) { - o = parse_psir(o, data.psir, data.psir_len); - free(data.psir); - } - - free(data.mpf_offsets); - return jv_set(o, jv_string("markers"), markers); -} - -// --- I/O --------------------------------------------------------------------- - -static jv -do_file(const char *filename, jv o) -{ - const char *err = NULL; - FILE *fp = fopen(filename, "rb"); - if (!fp) { - err = strerror(errno); - goto error; - } - - uint8_t *data = NULL, buf[256 << 10]; - size_t n, len = 0; - while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) { - data = realloc(data, len + n); - memcpy(data + len, buf, n); - len += n; - } - if (ferror(fp)) { - err = strerror(errno); - goto error_read; - } - -#if 0 - // Not sure if I want to ensure their existence... - o = jv_object_set(o, jv_string("info"), jv_array()); - o = jv_object_set(o, jv_string("warnings"), jv_array()); -#endif - - o = parse_jpeg(o, data, len); -error_read: - fclose(fp); - free(data); -error: - if (err) - o = add_error(o, err); - return o; -} - -int -main(int argc, char *argv[]) -{ - // XXX: Can't use `xargs -P0`, there's a risk of non-atomic writes. - // Usage: find . -iname *.png -print0 | xargs -0 ./pnginfo - for (int i = 1; i < argc; i++) { - const char *filename = argv[i]; - - jv o = jv_object(); - o = jv_object_set(o, jv_string("filename"), jv_string(filename)); - o = do_file(filename, o); - jv_dumpf(o, stdout, 0 /* Might consider JV_PRINT_SORTED. */); - fputc('\n', stdout); - } - return 0; -} diff --git a/tools/rawinfo.c b/tools/rawinfo.c new file mode 100644 index 0000000..6409d33 --- /dev/null +++ b/tools/rawinfo.c @@ -0,0 +1,175 @@ +// +// rawinfo.c: acquire information about raw image files in JSON format +// +// Copyright (c) 2023, 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 "info.h" + +#include <jv.h> +#include <libraw.h> + +#if LIBRAW_VERSION < LIBRAW_MAKE_VERSION(0, 21, 0) +#error LibRaw 0.21.0 or newer is required. +#endif + +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +// --- Raw image files --------------------------------------------------------- +// This is in principle similar to LibRaw's `raw-identify -v`, +// but the output is machine-processable. + +static jv +parse_raw(jv o, const uint8_t *p, size_t len) +{ + libraw_data_t *iprc = libraw_init(LIBRAW_OPIONS_NO_DATAERR_CALLBACK); + if (!iprc) + return add_error(o, "failed to obtain a LibRaw handle"); + + int err = 0; + if ((err = libraw_open_buffer(iprc, p, len))) { + libraw_close(iprc); + return add_error(o, libraw_strerror(err)); + } + + // -> iprc->rawparams.shot_select + o = jv_set(o, jv_string("count"), jv_number(iprc->idata.raw_count)); + + o = jv_set(o, jv_string("width"), jv_number(iprc->sizes.width)); + o = jv_set(o, jv_string("height"), jv_number(iprc->sizes.height)); + o = jv_set(o, jv_string("flip"), jv_number(iprc->sizes.flip)); + o = jv_set(o, jv_string("pixel_aspect_ratio"), + jv_number(iprc->sizes.pixel_aspect)); + + if ((err = libraw_adjust_sizes_info_only(iprc))) { + o = add_warning(o, libraw_strerror(err)); + } else { + o = jv_set( + o, jv_string("output_width"), jv_number(iprc->sizes.iwidth)); + o = jv_set( + o, jv_string("output_height"), jv_number(iprc->sizes.iheight)); + } + + jv thumbnails = jv_array(); + for (int i = 0; i < iprc->thumbs_list.thumbcount; i++) { + libraw_thumbnail_item_t *item = iprc->thumbs_list.thumblist + i; + + const char *format = "?"; + switch (item->tformat) { + case LIBRAW_INTERNAL_THUMBNAIL_UNKNOWN: + format = "unknown"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_KODAK_THUMB: + format = "Kodak thumbnail"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_KODAK_YCBCR: + format = "Kodak YCbCr"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_KODAK_RGB: + format = "Kodak RGB"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_JPEG: + format = "JPEG"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_LAYER: + format = "layer"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_ROLLEI: + format = "Rollei"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_PPM: + format = "PPM"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_PPM16: + format = "PPM16"; + break; + case LIBRAW_INTERNAL_THUMBNAIL_X3F: + format = "X3F"; + break; + } + + jv to = JV_OBJECT( + jv_string("width"), jv_number(item->twidth), + jv_string("height"), jv_number(item->theight), + jv_string("flip"), jv_number(item->tflip), + jv_string("format"), jv_string(format)); + + if (item->tformat == LIBRAW_INTERNAL_THUMBNAIL_JPEG && + item->toffset > 0 && + (size_t) item->toffset + item->tlength <= len) { + to = jv_set(to, jv_string("JPEG"), + parse_jpeg(jv_object(), p + item->toffset, item->tlength)); + } + + thumbnails = jv_array_append(thumbnails, to); + } + + libraw_close(iprc); + return jv_set(o, jv_string("thumbnails"), thumbnails); +} + +// --- I/O --------------------------------------------------------------------- + +static jv +do_file(const char *filename, jv o) +{ + const char *err = NULL; + FILE *fp = fopen(filename, "rb"); + if (!fp) { + err = strerror(errno); + goto error; + } + + uint8_t *data = NULL, buf[256 << 10]; + size_t n, len = 0; + while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) { + data = realloc(data, len + n); + memcpy(data + len, buf, n); + len += n; + } + if (ferror(fp)) { + err = strerror(errno); + goto error_read; + } + + o = parse_raw(o, data, len); + +error_read: + fclose(fp); + free(data); +error: + if (err) + o = add_error(o, err); + return o; +} + +int +main(int argc, char *argv[]) +{ + // XXX: Can't use `xargs -P0`, there's a risk of non-atomic writes. + // Usage: find . -print0 | xargs -0 ./rawinfo + for (int i = 1; i < argc; i++) { + const char *filename = argv[i]; + + jv o = jv_object(); + o = jv_object_set(o, jv_string("filename"), jv_string(filename)); + o = do_file(filename, o); + jv_dumpf(o, stdout, 0 /* Might consider JV_PRINT_SORTED. */); + fputc('\n', stdout); + } + return 0; +} diff --git a/tools/tiffinfo.c b/tools/tiffinfo.c deleted file mode 100644 index da629c6..0000000 --- a/tools/tiffinfo.c +++ /dev/null @@ -1,79 +0,0 @@ -// -// tiffinfo.c: acquire information about TIFF files in JSON format -// -// Copyright (c) 2021, 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 "info.h" - -#include <jv.h> - -#include <errno.h> -#include <stdlib.h> -#include <string.h> -#include <stdbool.h> - -// This is essentially the same as jpeginfo.c, but we only have an Exif segment. -// TODO(p): Photoshop data and ICC profiles also have their tag, -// they're not currently processed. - -static jv -do_file(const char *filename, jv o) -{ - const char *err = NULL; - FILE *fp = fopen(filename, "rb"); - if (!fp) { - err = strerror(errno); - goto error; - } - - uint8_t *data = NULL, buf[256 << 10]; - size_t n, len = 0; - while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) { - data = realloc(data, len + n); - memcpy(data + len, buf, n); - len += n; - } - if (ferror(fp)) { - err = strerror(errno); - goto error_read; - } - - o = parse_exif(o, data, len); - -error_read: - fclose(fp); - free(data); -error: - if (err) - o = add_error(o, err); - return o; -} - -int -main(int argc, char *argv[]) -{ - // XXX: Can't use `xargs -P0`, there's a risk of non-atomic writes. - // Usage: find . -iname *.png -print0 | xargs -0 ./pnginfo - for (int i = 1; i < argc; i++) { - const char *filename = argv[i]; - - jv o = jv_object(); - o = jv_object_set(o, jv_string("filename"), jv_string(filename)); - o = do_file(filename, o); - jv_dumpf(o, stdout, 0 /* Might consider JV_PRINT_SORTED. */); - fputc('\n', stdout); - } - return 0; -} diff --git a/tools/webpinfo.c b/tools/webpinfo.c deleted file mode 100644 index f3417f9..0000000 --- a/tools/webpinfo.c +++ /dev/null @@ -1,133 +0,0 @@ -// -// webpinfo.c: acquire information about WebP files in JSON format -// -// Copyright (c) 2021, 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 "info.h" - -#include <jv.h> - -#include <errno.h> -#include <stdio.h> -#include <stdlib.h> -#include <string.h> - -// --- WebP -------------------------------------------------------------------- -// https://github.com/webmproject/libwebp/blob/master/doc/webp-container-spec.txt -// https://github.com/webmproject/libwebp/blob/master/doc/webp-lossless-bitstream-spec.txt -// https://datatracker.ietf.org/doc/html/rfc6386 -// -// Pretty versions, hopefully not outdated: -// https://developers.google.com/speed/webp/docs/riff_container -// https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification - -static jv -parse_webp(jv o, const uint8_t *p, size_t len) -{ - // libwebp won't let us simply iterate over all chunks, so handroll it. - if (len < 12 || memcmp(p, "RIFF", 4) || memcmp(p + 8, "WEBP", 4)) - return add_error(o, "not a WEBP file"); - - // TODO(p): This can still be parseable. - // TODO(p): Warn on trailing data. - uint32_t size = u32le(p + 4); - if (8 + size < len) - return add_error(o, "truncated file"); - - const uint8_t *end = p + 8 + size; - p += 12; - - jv chunks = jv_array(); - while (p < end) { - if (end - p < 8) { - o = add_warning(o, "framing mismatch"); - printf("%ld", end - p); - break; - } - - uint32_t chunk_size = u32le(p + 4); - uint32_t chunk_advance = (chunk_size + 1) & ~1; - if (p + 8 + chunk_advance > end) { - o = add_warning(o, "runaway chunk payload"); - break; - } - - char fourcc[5] = ""; - memcpy(fourcc, p, 4); - chunks = jv_array_append(chunks, jv_string(fourcc)); - p += 8; - - // TODO(p): Decode VP8 and VP8L chunk metadata. - if (!strcmp(fourcc, "EXIF")) - o = parse_exif(o, p, chunk_size); - if (!strcmp(fourcc, "ICCP")) - o = parse_icc(o, p, chunk_size); - p += chunk_advance; - } - return jv_set(o, jv_string("chunks"), chunks); -} - -// --- I/O --------------------------------------------------------------------- - -static jv -do_file(const char *filename, jv o) -{ - const char *err = NULL; - FILE *fp = fopen(filename, "rb"); - if (!fp) { - err = strerror(errno); - goto error; - } - - uint8_t *data = NULL, buf[256 << 10]; - size_t n, len = 0; - while ((n = fread(buf, sizeof *buf, sizeof buf / sizeof *buf, fp))) { - data = realloc(data, len + n); - memcpy(data + len, buf, n); - len += n; - } - if (ferror(fp)) { - err = strerror(errno); - goto error_read; - } - - o = parse_webp(o, data, len); -error_read: - fclose(fp); - free(data); -error: - if (err) - o = add_error(o, err); - return o; -} - -int -main(int argc, char *argv[]) -{ - (void) parse_psir; - - // XXX: Can't use `xargs -P0`, there's a risk of non-atomic writes. - // Usage: find . -iname *.png -print0 | xargs -0 ./pnginfo - for (int i = 1; i < argc; i++) { - const char *filename = argv[i]; - - jv o = jv_object(); - o = jv_object_set(o, jv_string("filename"), jv_string(filename)); - o = do_file(filename, o); - jv_dumpf(o, stdout, 0 /* Might consider JV_PRINT_SORTED. */); - fputc('\n', stdout); - } - return 0; -} diff --git a/wuffs-mirror-release-c b/wuffs-mirror-release-c deleted file mode 160000 -Subproject 123a5c6ede3c052aaf9bbef59afb9410baa2b40 @@ -17,6 +17,9 @@ #include <glib.h> +#include <stdlib.h> +#include <string.h> + /// Add `element` to the `output` set. `relation` is a map of sets of strings /// defining is-a relations, and is traversed recursively. static void |