From 6b0f2de39c2d8b9e8d346abd8548024da4ae8185 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 12 Jul 2026 17:51:22 +0300 Subject: showimage: loader, detect, viewer widget, EXIF orientation, tests (gt0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (task gt0): show one image with zoom/pan. - src/loader/detect.{c,h}: magic-byte format sniffing (JPEG/PNG/GIF/WebP/ TIFF/ICO/JXL/AVIF/HEIF) -> GgazeFormat; pure, no I/O, unit-testable. - src/loader/loader.{c,h}: loader_load() sniffs the header and dispatches to the first registered backend; GgazeLoaderBackend struct; pixbuf is the fallback (last, accepts UNKNOWN). M1 ships only the pixbuf backend. - src/loader/backends/pixbuf.c: GdkPixbufLoader decode + gdk_pixbuf_apply_embedded_orientation (decision #26) -> GdkTexture via gdk_memory_texture_new (avoids the deprecated gdk_texture_new_for_pixbuf). - src/viewer.{c,h}: GgazeViewer : GtkWidget custom widget (decision #31) — fit/100%/in/out zoom, cursor-centered zoom, drag-to-pan with clamping, dark background, GtkSnapshot render nodes. - src/window.c: open -> loader_load -> viewer_set_texture -> stack 'large'. - tests: unit test_detect (13 cases) + test_loader_pixbuf (plain/rotated-EXIF 8x4 orient6->4x8/png/rgba/missing/unsupported jxl-avif-heif/corrupt), integration test_open_and_show (fixture + rotated + ./sample-images skip-if-absent). 6/6 green; detect 97% / loader 91% / pixbuf 87% coverage. - fixtures: gen.py produces plain.jpg, rot6.jpg, small.png, rgba.png. - AGENTS.md: documents the ./sample-images optional test corpus convention. Sub-agent review fixes: use-after-free of c_name in ggaze_window_open (BLOCKER), gtk_stack_get_pages leak in test_window (BLOCKER), coverage gap, dead branch, viewer measure, _prefix/_cb naming, include order, extern in header, pan clamp, stale comments — all addressed. --- src/loader/backends/pixbuf.c | 121 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/loader/backends/pixbuf.c (limited to 'src/loader/backends') diff --git a/src/loader/backends/pixbuf.c b/src/loader/backends/pixbuf.c new file mode 100644 index 0000000..e66f461 --- /dev/null +++ b/src/loader/backends/pixbuf.c @@ -0,0 +1,121 @@ +/*:* + * ggaze — GdkPixbuf loader backend (fallback) + * + * Decodes any GdkPixbuf-supported format (PNG/JPEG/GIF/WebP/TIFF/ICO) via a + * GdkPixbufLoader, applies the embedded EXIF Orientation (decision #26) so the + * returned GdkTexture is upright, and hands the result to the caller. Acts as + * the fallback backend: can_load() returns TRUE for unknown formats too (let + * GdkPixbuf try) and FALSE only for formats owned by the JXL/AVIF/HEIF + * backends in M5. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include +#include +#include + +#include "../detect.h" +#include "../loader.h" + +static GdkTexture *_texture_from_pixbuf(GdkPixbuf *p_pix); + +static gboolean +_pixbuf_can_load(const guint8 *p_head, gsize u_len) { + switch (detect_format(p_head, u_len)) { + case GGAZE_FMT_JXL: + case GGAZE_FMT_AVIF: + case GGAZE_FMT_HEIF: + return (FALSE); /* owned by specific backends (M5) */ + case GGAZE_FMT_UNKNOWN: + case GGAZE_FMT_JPEG: + case GGAZE_FMT_PNG: + case GGAZE_FMT_GIF: + case GGAZE_FMT_WEBP: + case GGAZE_FMT_TIFF: + case GGAZE_FMT_ICO: + return (TRUE); + } + return (FALSE); /* unreachable; keeps -Wreturn-type calm */ +} + +static GdkTexture * +_pixbuf_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) { + gchar *c_buf = NULL; + gsize u_len = 0; + if (!g_file_load_contents(p_file, p_cancel, &c_buf, &u_len, NULL, p_err)) { + return (NULL); + } + + GdkPixbufLoader *p_loader = gdk_pixbuf_loader_new(); + GError *p_sub = NULL; + if (!gdk_pixbuf_loader_write(p_loader, (const guchar *)c_buf, u_len, + &p_sub)) { + g_propagate_error(p_err, p_sub); + g_object_unref(p_loader); + g_free(c_buf); + return (NULL); + } + + /* Close may fail on truncated data but a pixbuf may still be available. */ + if (!gdk_pixbuf_loader_close(p_loader, &p_sub)) { + if (p_sub != NULL) { + g_error_free(p_sub); + } + } + + GdkPixbuf *p_pix = gdk_pixbuf_loader_get_pixbuf(p_loader); + if (p_pix == NULL) { + g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_FAILED, + "could not decode image (GdkPixbuf produced no pixbuf)"); + g_object_unref(p_loader); + g_free(c_buf); + return (NULL); + } + + /* Honor EXIF Orientation so the texture is upright (decision #26). */ + GdkPixbuf *p_oriented = gdk_pixbuf_apply_embedded_orientation(p_pix); + GdkPixbuf *p_use = + (p_oriented != NULL) ? p_oriented : GDK_PIXBUF(g_object_ref(p_pix)); + + GdkTexture *p_tex = _texture_from_pixbuf(p_use); + + g_object_unref(p_use); + g_object_unref(p_loader); + g_free(c_buf); + return (p_tex); +} + +const GgazeLoaderBackend pixbuf_backend = { + .can_load = _pixbuf_can_load, + .load = _pixbuf_load, +}; + +/* Build a GdkTexture from a GdkPixbuf without the deprecated + * gdk_texture_new_for_pixbuf(). GdkPixbuf stores non-premultiplied R8G8B8A8 + * when it has alpha; otherwise we add an alpha channel first. */ +static GdkTexture * +_texture_from_pixbuf(GdkPixbuf *p_pix) { + g_return_val_if_fail(GDK_IS_PIXBUF(p_pix), NULL); + int i_w = gdk_pixbuf_get_width(p_pix); + int i_h = gdk_pixbuf_get_height(p_pix); + g_return_val_if_fail(i_w > 0 && i_h > 0, NULL); + + GdkPixbuf *p_rgba = gdk_pixbuf_get_has_alpha(p_pix) + ? GDK_PIXBUF(g_object_ref(p_pix)) + : gdk_pixbuf_add_alpha(p_pix, FALSE, 0, 0, 0); + if (p_rgba == NULL) { + return (NULL); + } + + int i_rowstride = gdk_pixbuf_get_rowstride(p_rgba); + guchar *p_pixels = gdk_pixbuf_get_pixels(p_rgba); + gsize u_len = (gsize)(i_h - 1) * (gsize)i_rowstride + (gsize)i_w * 4u; + GBytes *p_bytes = g_bytes_new_with_free_func( + p_pixels, u_len, (GDestroyNotify)g_object_unref, p_rgba); + GdkTexture *p_tex = gdk_memory_texture_new(i_w, i_h, GDK_MEMORY_R8G8B8A8, + p_bytes, (gsize)i_rowstride); + g_bytes_unref(p_bytes); + return (p_tex); +} \ No newline at end of file -- cgit v1.2.3