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 +++++++++++++++++++++++++++++++++++++++++++ src/loader/detect.c | 82 +++++++++++++++++++++++++++++ src/loader/detect.h | 40 ++++++++++++++ src/loader/loader.c | 67 ++++++++++++++++++++++++ src/loader/loader.h | 45 ++++++++++++++++ 5 files changed, 355 insertions(+) create mode 100644 src/loader/backends/pixbuf.c create mode 100644 src/loader/detect.c create mode 100644 src/loader/detect.h create mode 100644 src/loader/loader.c create mode 100644 src/loader/loader.h (limited to 'src/loader') 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 diff --git a/src/loader/detect.c b/src/loader/detect.c new file mode 100644 index 0000000..1806874 --- /dev/null +++ b/src/loader/detect.c @@ -0,0 +1,82 @@ +/*:* + * ggaze — image format detection + * + * Magic-byte sniffing. Pure function, no I/O, no GTK -> unit-testable. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "detect.h" + +#include + +GgazeFormat +detect_format(const guint8 *p_head, gsize u_len) { + if (p_head == NULL || u_len == 0) { + return (GGAZE_FMT_UNKNOWN); + } + + /* JPEG: FF D8 FF */ + if (u_len >= 3 && p_head[0] == 0xFF && p_head[1] == 0xD8 && + p_head[2] == 0xFF) { + return (GGAZE_FMT_JPEG); + } + + /* PNG: 89 50 4E 47 0D 0A 1A 0A */ + if (u_len >= 8 && p_head[0] == 0x89 && p_head[1] == 'P' && + p_head[2] == 'N' && p_head[3] == 'G' && p_head[4] == 0x0D && + p_head[5] == 0x0A && p_head[6] == 0x1A && p_head[7] == 0x0A) { + return (GGAZE_FMT_PNG); + } + + /* GIF: "GIF8" */ + if (u_len >= 4 && p_head[0] == 'G' && p_head[1] == 'I' && p_head[2] == 'F' && + p_head[3] == '8') { + return (GGAZE_FMT_GIF); + } + + /* WebP: RIFF .... WEBP */ + if (u_len >= 12 && memcmp(p_head, "RIFF", 4) == 0 && + memcmp(p_head + 8, "WEBP", 4) == 0) { + return (GGAZE_FMT_WEBP); + } + + /* TIFF: II 2A 00 (little) | MM 00 2A (big) */ + if (u_len >= 4 && ((p_head[0] == 'I' && p_head[1] == 'I' && + p_head[2] == 0x2A && p_head[3] == 0x00) || + (p_head[0] == 'M' && p_head[1] == 'M' && + p_head[2] == 0x00 && p_head[3] == 0x2A))) { + return (GGAZE_FMT_TIFF); + } + + /* ICO: 00 00 01 00 */ + if (u_len >= 4 && p_head[0] == 0x00 && p_head[1] == 0x00 && + p_head[2] == 0x01 && p_head[3] == 0x00) { + return (GGAZE_FMT_ICO); + } + + /* JPEG XL: codestream FF 0A, or container 00 00 00 0C "JXL " */ + if (u_len >= 2 && p_head[0] == 0xFF && p_head[1] == 0x0A) { + return (GGAZE_FMT_JXL); + } + if (u_len >= 12 && p_head[0] == 0x00 && p_head[1] == 0x00 && + p_head[2] == 0x00 && p_head[3] == 0x0C && + memcmp(p_head + 4, "JXL ", 4) == 0) { + return (GGAZE_FMT_JXL); + } + + /* AVIF / HEIF: ISO BMFF ftyp box at offset 4; brand at offset 8. */ + if (u_len >= 12 && memcmp(p_head + 4, "ftyp", 4) == 0) { + const guint8 *p_brand = p_head + 8; + if (memcmp(p_brand, "avif", 4) == 0 || memcmp(p_brand, "avis", 4) == 0) { + return (GGAZE_FMT_AVIF); + } + if (memcmp(p_brand, "heic", 4) == 0 || memcmp(p_brand, "heix", 4) == 0 || + memcmp(p_brand, "mif1", 4) == 0) { + return (GGAZE_FMT_HEIF); + } + } + + return (GGAZE_FMT_UNKNOWN); +} \ No newline at end of file diff --git a/src/loader/detect.h b/src/loader/detect.h new file mode 100644 index 0000000..95e549c --- /dev/null +++ b/src/loader/detect.h @@ -0,0 +1,40 @@ +#ifndef GGAZE_DETECT_H +#define GGAZE_DETECT_H + +/*:* + * ggaze — image format detection + * + * Content-sniffing (magic bytes), never extension-based. detect_format() takes + * the first N bytes of a file and returns the detected GgazeFormat. The loader + * uses this to dispatch to the right backend; see docs/architecture.md "Image + * decode" and docs/tech-stack.md. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include + +G_BEGIN_DECLS + +typedef enum { + GGAZE_FMT_UNKNOWN = 0, + GGAZE_FMT_JPEG, /* FF D8 FF */ + GGAZE_FMT_PNG, /* 89 50 4E 47 0D 0A 1A 0A */ + GGAZE_FMT_GIF, /* "GIF8" */ + GGAZE_FMT_WEBP, /* RIFF .... WEBP */ + GGAZE_FMT_TIFF, /* II 2A 00 | MM 00 2A */ + GGAZE_FMT_ICO, /* 00 00 01 00 */ + GGAZE_FMT_JXL, /* FF 0A | "....JXL " container */ + GGAZE_FMT_AVIF, /* ftyp avif/avis */ + GGAZE_FMT_HEIF /* ftyp heic/heix/mif1 */ +} GgazeFormat; + +/* Sniff p_head (u_len bytes) and return the detected format. Never reads + * past u_len. Returns GGAZE_FMT_UNKNOWN if the buffer is too short or + * unrecognized. */ +GgazeFormat detect_format(const guint8 *p_head, gsize u_len); + +G_END_DECLS + +#endif /* GGAZE_DETECT_H */ \ No newline at end of file diff --git a/src/loader/loader.c b/src/loader/loader.c new file mode 100644 index 0000000..b1c4851 --- /dev/null +++ b/src/loader/loader.c @@ -0,0 +1,67 @@ +/*:* + * ggaze — image loader dispatcher + * + * Reads a short header, sniffs the format, and hands off to the first backend + * whose can_load() accepts it. BACKENDS[] is ordered so format-specific + * backends (JXL/AVIF/HEIF, M5) win over the GdkPixbuf fallback, which is last + * and accepts GGAZE_FMT_UNKNOWN. M1 ships only the pixbuf backend. + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include "loader.h" + +#include +#include + +#include "detect.h" + +/* Registered backends, priority order (specific first, fallback LAST). + * pixbuf_backend must remain last: it accepts GGAZE_FMT_UNKNOWN. */ +static const GgazeLoaderBackend *BACKENDS[] = { + /* jxl_backend, avif_backend, heif_backend land here in M5. */ + &pixbuf_backend, +}; + +#define GGAZE_SNIFF_LEN 64 + +static gsize +_read_header(GFile *p_file, GCancellable *p_cancel, guint8 *p_head, gsize u_max, + GError **p_err) { + GError *p_sub = NULL; + GFileInputStream *p_in = g_file_read(p_file, p_cancel, &p_sub); + if (p_in == NULL) { + g_propagate_error(p_err, p_sub); + return (0); + } + gssize n = g_input_stream_read(G_INPUT_STREAM(p_in), p_head, u_max, p_cancel, + &p_sub); + g_object_unref(p_in); + if (n < 0) { + g_propagate_error(p_err, p_sub); + return (0); + } + return ((gsize)n); +} + +GdkTexture * +loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err) { + g_return_val_if_fail(G_IS_FILE(p_file), NULL); + + guint8 head[GGAZE_SNIFF_LEN]; + gsize u_read = _read_header(p_file, p_cancel, head, GGAZE_SNIFF_LEN, p_err); + if (u_read == 0 && p_err != NULL && *p_err != NULL) { + return (NULL); + } + + for (gsize u_i = 0; u_i < G_N_ELEMENTS(BACKENDS); u_i++) { + if (BACKENDS[u_i]->can_load(head, u_read)) { + return (BACKENDS[u_i]->load(p_file, p_cancel, p_err)); + } + } + + g_set_error(p_err, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED, + "unsupported or unrecognized image format"); + return (NULL); +} \ No newline at end of file diff --git a/src/loader/loader.h b/src/loader/loader.h new file mode 100644 index 0000000..0795097 --- /dev/null +++ b/src/loader/loader.h @@ -0,0 +1,45 @@ +#ifndef GGAZE_LOADER_H +#define GGAZE_LOADER_H + +/*:* + * ggaze — image loader + * + * Synchronous load API for M1; M3 adds loader_load_async/_finish on top of the + * same worker. The loader sniffs the format from the file header (detect.c) + * and dispatches to the first registered backend whose can_load() accepts the + * header. GdkPixbuf is the fallback backend (covers PNG/JPEG/GIF/WebP/TIFF/ICO + * and anything GdkPixbuf happens to understand); JXL/AVIF/HEIF get specific + * backends in M5. Every backend honors EXIF Orientation so the returned + * GdkTexture is upright (decision #26). See docs/architecture.md "Image + * decode". + * + * Copyright (c) 2026 ggaze contributors + * SPDX-License-Identifier: GPL-3.0-or-later + *:*/ + +#include +#include +#include + +G_BEGIN_DECLS + +/* A loader backend. Compiled in conditionally (meson feature options) and + * registered with the loader at link time. */ +typedef struct { + gboolean (*can_load)(const guint8 *p_head, gsize u_len); + GdkTexture *(*load)(GFile *p_file, GCancellable *p_cancel, + GError **p_err); +} GgazeLoaderBackend; + +/* Backends register a const instance; the dispatcher (loader.c) iterates + * BACKENDS[] in priority order. pixbuf_backend is the fallback and MUST stay + * last (it accepts GGAZE_FMT_UNKNOWN). */ +extern const GgazeLoaderBackend pixbuf_backend; + +/* Synchronously load p_file into a GdkTexture (EXIF orientation applied). + * Returns a new GdkTexture (caller owns it) or NULL with p_err set. */ +GdkTexture *loader_load(GFile *p_file, GCancellable *p_cancel, GError **p_err); + +G_END_DECLS + +#endif /* GGAZE_LOADER_H */ \ No newline at end of file -- cgit v1.2.3