diff --git a/fastiv-io.c b/fastiv-io.c index f99c385..67041d3 100644 --- a/fastiv-io.c +++ b/fastiv-io.c @@ -1,5 +1,5 @@ // -// fastiv-io.c: image loaders +// fastiv-io.c: image operations // // Copyright (c) 2021, Přemysl Eric Janouch // @@ -36,6 +36,8 @@ #ifdef HAVE_LIBWEBP #include #include +#include +#include #endif // HAVE_LIBWEBP #ifdef HAVE_LIBHEIF #include @@ -129,7 +131,7 @@ fastiv_io_all_supported_media_types(void) G_DEFINE_QUARK(fastiv-io-error-quark, fastiv_io_error) enum FastivIoError { - FASTIV_IO_ERROR_OPEN, + FASTIV_IO_ERROR_OPEN }; static void @@ -1934,6 +1936,7 @@ fastiv_io_open_from_data(const char *data, size_t len, const gchar *path, // 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; @@ -1947,6 +1950,159 @@ fastiv_io_open_from_data(const char *data, size_t len, const gchar *path, return surface; } +// --- Export ------------------------------------------------------------------ +#ifdef HAVE_LIBWEBP + +static WebPData +encode_lossless_webp(cairo_surface_t *surface) +{ + 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); + cairo_set_source_surface(cr, surface, 0, 0); + cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); + cairo_paint(cr); + cairo_destroy(cr); + surface = converted; + } else { + surface = cairo_surface_reference(surface); + } + + WebPConfig config = {}; + WebPPicture picture = {}; + if (!WebPConfigInit(&config) || + !WebPConfigLosslessPreset(&config, 6) || + !WebPPictureInit(&picture)) + goto fail; + + config.thread_level = true; + if (!WebPValidateConfig(&config)) + goto fail; + + picture.use_argb = true; + picture.width = w; + picture.height = h; + 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) + 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++) + *argb = wuffs_base__color_u32_argb_premul__as__color_u32_argb_nonpremul(*argb); + else + for (int i = h * picture.argb_stride; i-- > 0; argb++) + *argb |= 0xFF000000; + + WebPMemoryWriter writer = {}; + WebPMemoryWriterInit(&writer); + picture.writer = WebPMemoryWrite; + picture.custom_ptr = &writer; + if (!WebPEncode(&config, &picture)) + g_debug("WebPEncode: %d\n", picture.error_code); + +fail_compatibility: + WebPPictureFree(&picture); +fail: + cairo_surface_destroy(surface); + return (WebPData) {.bytes = writer.mem, .size = writer.size}; +} + +static gboolean +encode_webp_image(WebPMux *mux, cairo_surface_t *frame) +{ + WebPData bitstream = encode_lossless_webp(frame); + gboolean ok = WebPMuxSetImage(mux, &bitstream, true) == WEBP_MUX_OK; + WebPDataClear(&bitstream); + return ok; +} + +static gboolean +encode_webp_animation(WebPMux *mux, cairo_surface_t *page) +{ + gboolean ok = TRUE; + for (cairo_surface_t *frame = page; ok && frame; frame = + cairo_surface_get_user_data(frame, &fastiv_io_key_frame_next)) { + WebPMuxFrameInfo info = { + .bitstream = encode_lossless_webp(frame), + .duration = (intptr_t) cairo_surface_get_user_data( + frame, &fastiv_io_key_frame_duration), + .id = WEBP_CHUNK_ANMF, + .dispose_method = WEBP_MUX_DISPOSE_NONE, + .blend_method = WEBP_MUX_NO_BLEND, + }; + ok = WebPMuxPushFrame(mux, &info, true) == WEBP_MUX_OK; + WebPDataClear(&info.bitstream); + } + WebPMuxAnimParams params = { + .bgcolor = 0x00000000, // BGRA, curiously. + .loop_count = (uintptr_t) + cairo_surface_get_user_data(page, &fastiv_io_key_loops), + }; + return ok && WebPMuxSetAnimationParams(mux, ¶ms) == WEBP_MUX_OK; +} + +static gboolean +transfer_metadata(WebPMux *mux, const char *fourcc, cairo_surface_t *page, + const cairo_user_data_key_t *kind) +{ + GBytes *data = cairo_surface_get_user_data(page, kind); + if (!data) + return TRUE; + + gsize len = 0; + gconstpointer p = g_bytes_get_data(data, &len); + return WebPMuxSetChunk(mux, fourcc, &(WebPData) {.bytes = p, .size = len}, + false) == WEBP_MUX_OK; +} + +gboolean +fastiv_io_save(cairo_surface_t *page, cairo_surface_t *frame, const gchar *path, + GError **error) +{ + g_return_val_if_fail(page != NULL, FALSE); + g_return_val_if_fail(path != NULL, FALSE); + + gboolean ok = TRUE; + WebPMux *mux = WebPMuxNew(); + if (frame) + ok = encode_webp_image(mux, frame); + else if (!cairo_surface_get_user_data(page, &fastiv_io_key_frame_next)) + ok = encode_webp_image(mux, page); + else + ok = encode_webp_animation(mux, page); + + ok = ok && transfer_metadata(mux, "EXIF", page, &fastiv_io_key_exif); + ok = ok && transfer_metadata(mux, "ICCP", page, &fastiv_io_key_icc); + + WebPData assembled = {}; + WebPDataInit(&assembled); + if (!(ok = ok && WebPMuxAssemble(mux, &assembled) == WEBP_MUX_OK)) + set_error(error, "encoding failed"); + else + ok = g_file_set_contents( + path, (const gchar *) assembled.bytes, assembled.size, error); + + WebPMuxDelete(mux); + WebPDataClear(&assembled); + return ok; +} + +#endif // HAVE_LIBWEBP // --- Metadata ---------------------------------------------------------------- FastivIoOrientation @@ -1993,6 +2149,81 @@ fastiv_io_exif_orientation(const guint8 *tiff, gsize len) return FastivIoOrientationUnknown; } +gboolean +fastiv_io_save_metadata( + cairo_surface_t *page, const gchar *path, GError **error) +{ + g_return_val_if_fail(page != NULL, FALSE); + + FILE *fp = fopen(path, "wb"); + if (!fp) { + g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno), + "%s: %s", path, g_strerror(errno)); + return FALSE; + } + + // This does not constitute a valid JPEG codestream--it's a TEM marker + // (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, &fastiv_io_key_exif)) && + (p = g_bytes_get_data(data, &len))) { + while (len) { + gsize chunk = MIN(len, 0xFFFF - 2 - 6); + uint8_t header[10] = "\xFF\xE1\000\000Exif\000\000"; + header[2] = (chunk + 2 + 6) >> 8; + header[3] = (chunk + 2 + 6); + + fwrite(header, 1, sizeof header, fp); + fwrite(p, 1, chunk, fp); + + len -= chunk; + p += chunk; + } + } + + // https://www.color.org/specification/ICC1v43_2010-12.pdf B.4 + if ((data = cairo_surface_get_user_data(page, &fastiv_io_key_icc)) && + (p = g_bytes_get_data(data, &len))) { + gsize limit = 0xFFFF - 2 - 12; + uint8_t current = 0, total = (len + limit - 1) / limit; + while (len) { + gsize chunk = MIN(len, limit); + uint8_t header[18] = "\xFF\xE2\000\000ICC_PROFILE\000\000\000"; + header[2] = (chunk + 2 + 12 + 2) >> 8; + header[3] = (chunk + 2 + 12 + 2); + header[16] = ++current; + header[17] = total; + + fwrite(header, 1, sizeof header, fp); + fwrite(p, 1, chunk, fp); + + len -= chunk; + p += chunk; + } + } + + fprintf(fp, "\xFF\xD9"); + if (ferror(fp)) { + g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno), + "%s: %s", path, g_strerror(errno)); + fclose(fp); + return FALSE; + } + if (fclose(fp)) { + g_set_error(error, G_IO_ERROR, g_io_error_from_errno(errno), + "%s: %s", path, g_strerror(errno)); + return FALSE; + } + return TRUE; +} + // --- Thumbnails -------------------------------------------------------------- GType diff --git a/fastiv-io.h b/fastiv-io.h index c8a256b..a581ea8 100644 --- a/fastiv-io.h +++ b/fastiv-io.h @@ -1,5 +1,5 @@ // -// fastiv-io.h: image loaders +// fastiv-io.h: image operations // // Copyright (c) 2021, Přemysl Eric Janouch // @@ -59,6 +59,13 @@ cairo_surface_t *fastiv_io_open_from_data( int fastiv_io_filecmp(GFile *f1, GFile *f2); +// --- Export ------------------------------------------------------------------ + +/// Requires libwebp. +/// If no exact frame is specified, this potentially creates an animation. +gboolean fastiv_io_save(cairo_surface_t *page, cairo_surface_t *frame, + const gchar *path, GError **error); + // --- Metadata ---------------------------------------------------------------- // https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf Table 6 @@ -76,6 +83,10 @@ typedef enum _FastivIoOrientation { FastivIoOrientation fastiv_io_exif_orientation(const guint8 *exif, gsize len); +/// Save metadata attached by this module in Exiv2 format. +gboolean fastiv_io_save_metadata( + cairo_surface_t *page, const gchar *path, GError **error); + // --- Thumbnails -------------------------------------------------------------- // And this is how you avoid glib-mkenums. diff --git a/fastiv-view.c b/fastiv-view.c index db11ae1..3aefefd 100644 --- a/fastiv-view.c +++ b/fastiv-view.c @@ -15,6 +15,8 @@ // CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // +#include "config.h" + #include #include @@ -549,6 +551,86 @@ fastiv_view_unmap(GtkWidget *widget) GTK_WIDGET_CLASS(fastiv_view_parent_class)->unmap(widget); } +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +show_error_dialog(GtkWindow *parent, GError *error) +{ + GtkWidget *dialog = gtk_message_dialog_new(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 gboolean +save_as(FastivView *self, gboolean frame) +{ + GtkWindow *window = NULL; + GtkWidget *widget = NULL; + if (GTK_IS_WINDOW((widget = gtk_widget_get_toplevel(GTK_WIDGET(self))))) + window = GTK_WINDOW(widget); + + GtkWidget *dialog = + gtk_file_chooser_dialog_new(frame ? "Save frame as" : "Save page as", + window, GTK_FILE_CHOOSER_ACTION_SAVE, + "_Cancel", GTK_RESPONSE_CANCEL, "_Save", GTK_RESPONSE_ACCEPT, NULL); + GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog); + + // TODO(p): Consider a hard dependency on libwebp, or clean this up. +#ifdef HAVE_LIBWEBP + // This is the best general format: supports lossless encoding, animations, + // alpha channel, and Exif and ICC profile metadata. + // PNG is another viable option, but sPNG can't do APNG, Wuffs can't save, + // and libpng is a pain in the arse. + GtkFileFilter *webp_filter = gtk_file_filter_new(); + gtk_file_filter_add_mime_type(webp_filter, "image/webp"); + gtk_file_filter_add_pattern(webp_filter, "*.webp"); + gtk_file_filter_set_name(webp_filter, "Lossless WebP"); + gtk_file_chooser_add_filter(chooser, webp_filter); + + // TODO(p): Derive it from the currently displayed filename, + // and set the directory to the same place. + gtk_file_chooser_set_current_name( + chooser, frame ? "frame.webp" : "page.webp"); +#endif // HAVE_LIBWEBP + + // The format is supported by Exiv2 and ExifTool. + // This is mostly a developer tool. + GtkFileFilter *exv_filter = gtk_file_filter_new(); + gtk_file_filter_add_mime_type(exv_filter, "image/x-exv"); + gtk_file_filter_add_pattern(exv_filter, "*.exv"); + gtk_file_filter_set_name(exv_filter, "Exiv2 metadata"); + gtk_file_chooser_add_filter(chooser, exv_filter); + + switch (gtk_dialog_run(GTK_DIALOG(dialog))) { + gchar *path; + case GTK_RESPONSE_ACCEPT: + path = gtk_file_chooser_get_filename(chooser); + + GError *error = NULL; +#ifdef HAVE_LIBWEBP + if (gtk_file_chooser_get_filter(chooser) == webp_filter) + fastiv_io_save(self->page, + frame ? self->frame : NULL, path, &error); + else +#endif // HAVE_LIBWEBP + fastiv_io_save_metadata(self->page, path, &error); + if (error) + show_error_dialog(window, error); + g_free(path); + + // Fall-through. + default: + gtk_widget_destroy(dialog); + // Fall-through. + case GTK_RESPONSE_NONE: + return TRUE; + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + static gboolean fastiv_view_key_press_event(GtkWidget *widget, GdkEventKey *event) { @@ -569,6 +651,10 @@ fastiv_view_key_press_event(GtkWidget *widget, GdkEventKey *event) return set_scale(self, self->scale * SCALE_STEP); case GDK_KEY_minus: return set_scale(self, self->scale / SCALE_STEP); + case GDK_KEY_s: + return save_as(self, FALSE); + case GDK_KEY_S: + return save_as(self, TRUE); } } if (state != 0) diff --git a/meson.build b/meson.build index 7b8cb20..2bf6d79 100644 --- a/meson.build +++ b/meson.build @@ -20,6 +20,8 @@ xcursor = dependency('xcursor', required : get_option('xcursor')) libwebp = dependency('libwebp', required : get_option('libwebp')) libwebpdemux = dependency('libwebpdemux', required : get_option('libwebp')) libwebpdecoder = dependency('libwebpdecoder', required : get_option('libwebp')) +libwebpmux = dependency('libwebpmux', required : get_option('libwebp')) +libwebpencoder = dependency('libwebpencoder', required : get_option('libwebp')) libheif = dependency('libheif', required : get_option('libheif')) libtiff = dependency('libtiff-4', required : get_option('libtiff')) gdkpixbuf = dependency('gdk-pixbuf-2.0', required : get_option('gdk-pixbuf')) @@ -35,6 +37,8 @@ dependencies = [ libwebp, libwebpdemux, libwebpdecoder, + libwebpmux, + libwebpencoder, libheif, libtiff, gdkpixbuf,