diff --git a/.gitignore b/.gitignore index c0622f45..4510679a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ nemo-compare/debian/nemo-compare/ nemo-dropbox/debian/nemo-dropbox/ nemo-emblems/debian/nemo-emblems/ nemo-fileroller/debian/nemo-fileroller/ +nemo-fits/debian/nemo-fits/ nemo-image-converter/debian/nemo-image-converter/ nemo-media-columns/debian/nemo-media-columns/ nemo-pastebin/debian/nemo-pastebin/ @@ -29,3 +30,4 @@ nemo-seahorse/debian/nemo-seahorse/ nemo-share/debian/nemo-share/ nemo-terminal/debian/nemo-terminal/ tmp/ +*.vscode diff --git a/build-order b/build-order index 3cffa070..52efffcc 100644 --- a/build-order +++ b/build-order @@ -6,6 +6,7 @@ nemo-compare nemo-dropbox nemo-emblems nemo-fileroller +nemo-fits nemo-image-converter nemo-media-columns nemo-pastebin diff --git a/makepot b/makepot index e4fd6e0e..84b5178b 100755 --- a/makepot +++ b/makepot @@ -27,6 +27,8 @@ potfiles_c=( nemo-dropbox/src/*.h nemo-fileroller/src/*.c nemo-fileroller/src/*.h + nemo-fits/*.c + nemo-fits/*.h nemo-image-converter/data/*.ui.h nemo-image-converter/src/*.c nemo-image-converter/src/*.h diff --git a/nemo-fits/README b/nemo-fits/README new file mode 100644 index 00000000..3931d288 --- /dev/null +++ b/nemo-fits/README @@ -0,0 +1,20 @@ +Nemo Extension For fits Files + +About +nemo-fits is a nemo extension which provides: +A thumbnail of .fits files. +Information from the .fits file header in the Image tab. +An option to autostretch the thumbnail. + +Project page: +https://github.com/linuxmint/nemo-extensions/ + +Bug Tracker +https://github.com/linuxmint/nemo-extensions/issues/ + +Install +$ ./configure +$ make +# make install + +Please read INSTALL, for detailed installation instructions. diff --git a/nemo-fits/debian/changelog b/nemo-fits/debian/changelog new file mode 100644 index 00000000..11663be3 --- /dev/null +++ b/nemo-fits/debian/changelog @@ -0,0 +1,10 @@ +nemo-fits (6.7.0-1) wilma; urgency=medium + + * Initial release + * View FITS image metadata in properties tab + * Provide thumbnails + * Optional auto-stretch for thumbnail and preview images using PixInsight + median/MADN algorithm (ported from KStars/Ekos) enabled by persistent toggle in properties page + * RGB colour image support for FITS cube format (NAXIS3=3) + + -- Linux Mint Sat, 29 Aug 2026 12:00:00 +0000 \ No newline at end of file diff --git a/nemo-fits/debian/control b/nemo-fits/debian/control new file mode 100644 index 00000000..f52f1d4c --- /dev/null +++ b/nemo-fits/debian/control @@ -0,0 +1,37 @@ +Source: nemo-fits +Section: gnome +Priority: optional +Maintainer: Linux Mint +Build-Depends: debhelper-compat (= 13), + meson (>= 0.56.0), + libglib2.0-dev (>= 2.50.0), + libgtk-3-dev (>= 3.22.0), + libnemo-extension-dev (>= 3.0.0), + libcfitsio-dev (>= 3.0), + gettext, + intltool +Standards-Version: 4.6.0 +Homepage: https://github.com/linuxmint/nemo-extensions + +Package: nemo-fits +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nemo (>= 3.0.0), + libcfitsio9 | libcfitsio10 +Description: Nemo extension for FITS files + A Nemo extension that provides an "Image" tab + in the file properties dialog for FITS files commonly used in astronomy. + . + The extension displays metadata from FITS headers including: + - Image dimensions, colour mode, and data type + - Object name and observation details + - Telescope and instrument information + - Date of observation and exposure time + - Filter, Bayer pattern, and coordinate system information + . + A live preview with toggleable auto-stretch is shown in the properties tab. + The auto-stretch setting is saved persistently per user. + . + A thumbnailer is also provided, supporting greyscale and RGB colour images, + with auto-stretch applied by default. \ No newline at end of file diff --git a/nemo-fits/debian/rules b/nemo-fits/debian/rules new file mode 100755 index 00000000..b10a2207 --- /dev/null +++ b/nemo-fits/debian/rules @@ -0,0 +1,9 @@ +#!/usr/bin/make -f + +export DEB_BUILD_MAINT_OPTIONS = hardening=+bindnow + +%: + dh $@ --buildsystem=meson + +# Don't generate a shlibs file for the plugin +override_dh_makeshlibs: \ No newline at end of file diff --git a/nemo-fits/fits-autostretch.c b/nemo-fits/fits-autostretch.c new file mode 100644 index 00000000..098cdb4f --- /dev/null +++ b/nemo-fits/fits-autostretch.c @@ -0,0 +1,305 @@ +/* fits-autostretch.c + * + * Auto-stretch for FITS image data — implementation. + * Ported from KStars/Ekos stretch.cpp; algorithm based on PixInsight XISF + * spec §8.5.6–8.5.7: + * https://pixinsight.com/doc/docs/XISF-1.0-spec/XISF-1.0-spec.html + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "fits-autostretch.h" + +#include +#include +#include + +/* ------------------------------------------------------------------------- + * Preset table: {B, C} pairs matching KStars presets 1–7. + * B = target midtone brightness + * C = contrast / clipping aggressiveness + * ------------------------------------------------------------------------- */ + +static const struct { double B; double C; } stretch_presets[] = { + { 0.25, 2.8 }, /* 1 – default */ + { 0.25, 1.5 }, /* 2 – softer */ + { 0.125, 1.0 }, /* 3 – dark / subtle */ + { 0.125, 0.5 }, /* 4 – very subtle */ + { 0.125, 2.0 }, /* 5 – medium dark */ + { 0.125, 4.0 }, /* 6 – aggressive */ + { 0.25, 5.5 }, /* 7 – very aggressive */ +}; + +/* ------------------------------------------------------------------------- + * Image type detection + * ------------------------------------------------------------------------- */ + +FitsImageType +fits_detect_image_type (fitsfile *fptr, int naxis, const long *naxes) +{ + char bayer[FLEN_VALUE]; + int status = 0; + + if (naxis == 2) { + if (!fits_read_key_str (fptr, "BAYERPAT", bayer, NULL, &status)) + return IMAGE_BAYER; + return IMAGE_GREY; + } + + if (naxis == 3) { + if (naxes[2] == 3) return IMAGE_RGB; + if (naxes[2] == 1) return IMAGE_GREY; /* degenerate third axis */ + } + + return IMAGE_UNKNOWN; +} + +/* ------------------------------------------------------------------------- + * Buffer helpers + * ------------------------------------------------------------------------- */ + +void +fits_find_range (const double *buf, long n, double *min_out, double *max_out) +{ + double mn = INFINITY; + double mx = -INFINITY; + + for (long i = 0; i < n; i++) { + if (isfinite (buf[i])) { + if (buf[i] < mn) mn = buf[i]; + if (buf[i] > mx) mx = buf[i]; + } + } + *min_out = mn; + *max_out = mx; +} + +void +fits_normalise_plane (const double *raw, long n, + double min_val, double range_inv, double *out) +{ + for (long i = 0; i < n; i++) + out[i] = isfinite (raw[i]) ? (raw[i] - min_val) * range_inv : 0.0; +} + +unsigned char * +fits_grey_to_rgb_flipped (const unsigned char *grey, long width, long height) +{ + long npixels = width * height; + unsigned char *rgb = (unsigned char *)malloc (npixels * 3); + if (!rgb) return NULL; + + for (long i = 0; i < npixels; i++) { + long row = i / width; + long col = i % width; + long dst = (height - 1 - row) * width + col; + rgb[dst * 3] = grey[i]; + rgb[dst * 3 + 1] = grey[i]; + rgb[dst * 3 + 2] = grey[i]; + } + return rgb; +} + +unsigned char * +fits_rgb_flip_vertical (const unsigned char *rgb_in, long width, long height) +{ + long row_bytes = width * 3; + unsigned char *out = (unsigned char *)malloc (height * row_bytes); + if (!out) return NULL; + + for (long row = 0; row < height; row++) + memcpy (out + (height - 1 - row) * row_bytes, + rgb_in + row * row_bytes, + row_bytes); + return out; +} + +/* ------------------------------------------------------------------------- + * Internal helpers + * ------------------------------------------------------------------------- */ + +static int +stretch_cmp_double (const void *a, const void *b) +{ + double da = *(const double *)a; + double db = *(const double *)b; + return (da > db) - (da < db); +} + +static double +stretch_median (const double *buf, long n, int sample_by) +{ + long n_samples = n / sample_by; + if (n_samples < 1) n_samples = 1; + + double *tmp = (double *)malloc (n_samples * sizeof(double)); + if (!tmp) return 0.0; + + for (long i = 0, idx = 0; i < n_samples; i++, idx += sample_by) + tmp[i] = buf[idx]; + + qsort (tmp, n_samples, sizeof(double), stretch_cmp_double); + double med = tmp[n_samples / 2]; + free (tmp); + return med; +} + +/* Core parameter computation for a single normalised (0..1) pixel plane. */ +static void +stretch_params_one (const double *buf, long n, int preset, StretchParams *out) +{ + if (preset < 1) preset = 1; + if (preset > STRETCH_NUM_PRESETS) preset = STRETCH_NUM_PRESETS; + + const double B = stretch_presets[preset - 1].B; + const double C = stretch_presets[preset - 1].C; + + out->shadows = 0.0; + out->highlights = 1.0; + out->midtones = 0.5; + + if (n <= 0) return; + + const long max_samples = 500000L; + int sample_by = (n < max_samples) ? 1 : (int)(n / max_samples); + + double med = stretch_median (buf, n, sample_by); + + long n_samples = n / sample_by; + double *dev = (double *)malloc (n_samples * sizeof(double)); + if (!dev) return; + + for (long i = 0, idx = 0; i < n_samples; i++, idx += sample_by) { + double d = buf[idx] - med; + dev[i] = d < 0.0 ? -d : d; + } + qsort (dev, n_samples, sizeof(double), stretch_cmp_double); + double MADN = 1.4826 * dev[n_samples / 2]; + free (dev); + + int upper_half = (med > 0.5); + + double shadows, highlights; + if (upper_half || MADN == 0.0) + shadows = 0.0; + else + shadows = fmin (1.0, fmax (0.0, med - C * MADN)); + + if (!upper_half || MADN == 0.0) + highlights = 1.0; + else + highlights = fmin (1.0, fmax (0.0, med + C * MADN)); + + double X, M; + if (!upper_half) { X = med - shadows; M = B; } + else { X = B; M = highlights - med; } + + double midtones; + if (X == 0.0) midtones = 0.0; + else if (X == M) midtones = 0.5; + else if (X == 1.0) midtones = 1.0; + else midtones = ((M - 1.0) * X) / ((2.0 * M - 1.0) * X - M); + + out->shadows = shadows; + out->highlights = highlights; + out->midtones = midtones; +} + +/* Apply one channel's stretch params to a normalised pixel plane. + * + * in : normalised input plane (double, length n) + * n : number of pixels + * p : stretch parameters + * out : first byte of this channel in the output buffer + * out_stride : byte distance between successive output pixels for this channel. + * Use 1 for a packed greyscale plane; use 3 for an interleaved + * RGB buffer, pointing out at the R, G, or B byte. */ +static void +stretch_apply_one (const double *in, long n, const StretchParams *p, + unsigned char *out, int out_stride) +{ + const double shadows = p->shadows; + const double highlights = p->highlights; + const double midtones = p->midtones; + + const double hs_inv = (highlights == shadows) ? 1.0 + : 1.0 / (highlights - shadows); + const double k1 = (midtones - 1.0) * hs_inv * 255.0; + const double k2 = (2.0 * midtones - 1.0) * hs_inv; + + for (long i = 0; i < n; i++) { + double v = in[i]; + unsigned char val; + + if (v < shadows) { + val = 0; + } else if (v >= highlights) { + val = 255; + } else { + double vf = v - shadows; + double s = (vf * k1) / (vf * k2 - midtones); + if (s < 0.0) val = 0; + else if (s > 255.0) val = 255; + else val = (unsigned char)s; + } + + out[i * out_stride] = val; + } +} + +/* ------------------------------------------------------------------------- + * Public API — 1-channel (greyscale) + * ------------------------------------------------------------------------- */ + +void +fits_autostretch_params (const double *buf, long n, int preset, + StretchParams *out) +{ + stretch_params_one (buf, n, preset, out); +} + +void +fits_autostretch_apply (const double *buf, long n, + const StretchParams *p, unsigned char *out_grey) +{ + stretch_apply_one (buf, n, p, out_grey, 1); +} + +void +fits_autostretch (const double *buf, long n, int preset, unsigned char *out_grey) +{ + StretchParams p; + stretch_params_one (buf, n, preset, &p); + stretch_apply_one (buf, n, &p, out_grey, 1); +} + +/* ------------------------------------------------------------------------- + * Public API — 3-channel (planar RGB) + * ------------------------------------------------------------------------- */ + +void +fits_autostretch_params3 (const double *planes, long npixels, int preset, + StretchParams3 *out) +{ + stretch_params_one (planes, npixels, preset, &out->r); + stretch_params_one (planes + npixels, npixels, preset, &out->g); + stretch_params_one (planes + npixels * 2, npixels, preset, &out->b); +} + +void +fits_autostretch_apply3 (const double *planes, long npixels, + const StretchParams3 *p, unsigned char *out_rgb) +{ + stretch_apply_one (planes, npixels, &p->r, out_rgb + 0, 3); + stretch_apply_one (planes + npixels, npixels, &p->g, out_rgb + 1, 3); + stretch_apply_one (planes + npixels * 2, npixels, &p->b, out_rgb + 2, 3); +} + +void +fits_autostretch3 (const double *planes, long npixels, int preset, + unsigned char *out_rgb) +{ + StretchParams3 p; + fits_autostretch_params3 (planes, npixels, preset, &p); + fits_autostretch_apply3 (planes, npixels, &p, out_rgb); +} \ No newline at end of file diff --git a/nemo-fits/fits-autostretch.h b/nemo-fits/fits-autostretch.h new file mode 100644 index 00000000..057a269f --- /dev/null +++ b/nemo-fits/fits-autostretch.h @@ -0,0 +1,119 @@ +/* fits-autostretch.h + * + * Auto-stretch for FITS image data — public interface. + * Ported from KStars/Ekos stretch.cpp; algorithm based on PixInsight XISF + * spec §8.5.6–8.5.7: + * https://pixinsight.com/doc/docs/XISF-1.0-spec/XISF-1.0-spec.html + * + * SPDX-License-Identifier: GPL-2.0-or-later + * + * Operates on double-precision input buffers, outputs 8-bit pixel data. + * + * Supports greyscale (1-channel) and RGB (3-channel, planar) images. + * For 3-channel images, planes are stored sequentially: + * all R pixels, then all G pixels, then all B pixels (FITS cube convention). + */ + +#pragma once + +#include + +/* ------------------------------------------------------------------------- + * Image type detection + * ------------------------------------------------------------------------- */ + +typedef enum { + IMAGE_GREY, /* NAXIS=2, or NAXIS=3 with NAXIS3=1 */ + IMAGE_RGB, /* NAXIS=3 with NAXIS3=3 (sequential colour planes) */ + IMAGE_BAYER, /* NAXIS=2 with BAYERPAT keyword — rendered as grey */ + IMAGE_UNKNOWN /* anything else: attempt greyscale of first plane */ +} FitsImageType; + +/* Inspect an open fitsfile and return how its image data should be rendered. */ +FitsImageType fits_detect_image_type (fitsfile *fptr, int naxis, + const long *naxes); + +/* ------------------------------------------------------------------------- + * Buffer helpers + * ------------------------------------------------------------------------- */ + +/* Walk buf of length n and return the min and max finite values. */ +void fits_find_range (const double *buf, long n, + double *min_out, double *max_out); + +/* Normalise n values from raw into out using pre-computed min and reciprocal + * range. Non-finite pixels are clamped to 0. */ +void fits_normalise_plane (const double *raw, long n, + double min_val, double range_inv, double *out); + +/* Convert a bottom-up greyscale byte plane into a top-down interleaved RGB + * buffer (R=G=B). Returns a malloc'd buffer the caller must free, or NULL + * on allocation failure. */ +unsigned char *fits_grey_to_rgb_flipped (const unsigned char *grey, + long width, long height); + +/* Flip a bottom-up interleaved RGB buffer to top-down order. + * Returns a malloc'd buffer the caller must free, or NULL on failure. */ +unsigned char *fits_rgb_flip_vertical (const unsigned char *rgb_in, + long width, long height); + +/* ------------------------------------------------------------------------- + * Stretch parameters + * ------------------------------------------------------------------------- */ + +typedef struct { + double shadows; + double highlights; + double midtones; +} StretchParams; + +typedef struct { + StretchParams r; + StretchParams g; + StretchParams b; +} StretchParams3; + +/* Number of available presets (1-based index passed to API calls below). + * Preset 1 (B=0.25, C=2.8) is the recommended default. */ +#define STRETCH_NUM_PRESETS 7 + +/* ------------------------------------------------------------------------- + * Public API — 1-channel (greyscale) + * ------------------------------------------------------------------------- */ + +/* Compute stretch parameters for a single normalised (0..1) pixel plane. */ +void fits_autostretch_params (const double *buf, long n, int preset, + StretchParams *out); + +/* Apply stretch parameters to buf, writing 8-bit results to out_grey. */ +void fits_autostretch_apply (const double *buf, long n, + const StretchParams *p, unsigned char *out_grey); + +/* Compute params and apply in one call. */ +void fits_autostretch (const double *buf, long n, int preset, + unsigned char *out_grey); + +/* ------------------------------------------------------------------------- + * Public API — 3-channel (planar RGB) + * + * Input convention (FITS cube layout): + * planes[0 .. npixels-1] = R plane + * planes[npixels .. 2*npixels-1] = G plane + * planes[2*npixels .. 3*npixels-1] = B plane + * + * Output convention: + * out_rgb is interleaved: R0 G0 B0 R1 G1 B1 ... (length 3*npixels bytes) + * ------------------------------------------------------------------------- */ + +/* Compute independent stretch parameters for each of three planar channels. */ +void fits_autostretch_params3 (const double *planes, long npixels, int preset, + StretchParams3 *out); + +/* Apply per-channel stretch parameters to three planar inputs, writing + * interleaved 8-bit RGB output. */ +void fits_autostretch_apply3 (const double *planes, long npixels, + const StretchParams3 *p, unsigned char *out_rgb); + +/* Compute params and apply in one call (3-channel planar). */ +void fits_autostretch3 (const double *planes, long npixels, int preset, + unsigned char *out_rgb); \ No newline at end of file diff --git a/nemo-fits/fits-render.c b/nemo-fits/fits-render.c new file mode 100644 index 00000000..8959837e --- /dev/null +++ b/nemo-fits/fits-render.c @@ -0,0 +1,159 @@ +/* fits-render.c + * + * Shared FITS image rendering pipeline — implementation. + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "fits-render.h" +#include "fits-autostretch.h" + +#include +#include +#include +#include + +/* Stretch preset used for all rendered thumbnails and previews. + * Preset 1: B=0.25, C=2.8 — the KStars default. */ +#define RENDER_STRETCH_PRESET 1 + +GdkPixbuf * +fits_render_pixbuf (const char *path, gboolean do_stretch, int max_size) +{ + fitsfile *fptr = NULL; + int status = 0, naxis; + long naxes[10]; + double *raw = NULL; + double *norm = NULL; + guchar *ch_out = NULL; + guchar *rgb = NULL; + GdkPixbuf *pixbuf = NULL; + GdkPixbuf *scaled = NULL; + + if (fits_open_file (&fptr, path, READONLY, &status)) + return NULL; + + /* Read NAXIS */ + if (fits_read_key (fptr, TINT, "NAXIS", &naxis, NULL, &status) || naxis < 2) + goto done; + + /* Read NAXISn — treat any single key failure as fatal since we cannot + * safely compute image dimensions with a partially initialised array. */ + for (int i = 1; i <= naxis && i <= 10; i++) { + char key[9]; + g_snprintf (key, sizeof (key), "NAXIS%d", i); + status = 0; + if (fits_read_key (fptr, TLONG, key, &naxes[i - 1], NULL, &status)) + goto done; + } + + { + const long width = naxes[0]; + const long height = naxes[1]; + const long npixels = width * height; + + if (width == 0 || height == 0) + goto done; + + FitsImageType itype = fits_detect_image_type (fptr, naxis, naxes); + const int nplanes = (itype == IMAGE_RGB) ? 3 : 1; + const long total = npixels * nplanes; + + /* Read all pixel data in one call */ + raw = g_malloc (total * sizeof (double)); + if (!raw) goto done; + + long fpixel[10] = { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }; + if (fits_read_pix (fptr, TDOUBLE, fpixel, total, NULL, raw, NULL, &status)) + goto done; + + /* Find data range */ + double min_val, max_val; + fits_find_range (raw, total, &min_val, &max_val); + + if (!isfinite (min_val) || !isfinite (max_val) || max_val <= min_val) { + /* Flat or empty image: return a mid-grey pixbuf */ + guchar *flat = g_malloc (npixels * 3); + if (!flat) goto done; + memset (flat, 128, npixels * 3); + pixbuf = gdk_pixbuf_new_from_data (flat, GDK_COLORSPACE_RGB, FALSE, + 8, width, height, width * 3, + (GdkPixbufDestroyNotify)g_free, + NULL); + if (!pixbuf) g_free (flat); + goto scale_and_return; + } + + /* Normalise to 0..1 */ + norm = g_malloc (total * sizeof (double)); + if (!norm) goto done; + fits_normalise_plane (raw, total, min_val, 1.0 / (max_val - min_val), norm); + + /* Stretch and assemble */ + if (itype == IMAGE_RGB) { + ch_out = g_malloc (npixels * 3); + if (!ch_out) goto done; + + if (do_stretch) { + fits_autostretch3 (norm, npixels, RENDER_STRETCH_PRESET, ch_out); + } else { + for (long i = 0; i < npixels; i++) { + ch_out[i * 3] = (guchar)(norm[i] * 255.0 + 0.5); + ch_out[i * 3 + 1] = (guchar)(norm[i + npixels] * 255.0 + 0.5); + ch_out[i * 3 + 2] = (guchar)(norm[i + npixels*2] * 255.0 + 0.5); + } + } + + rgb = (guchar *)fits_rgb_flip_vertical (ch_out, width, height); + g_free (ch_out); + ch_out = NULL; + + } else { + /* Greyscale path — also covers BAYER and UNKNOWN (first plane only) */ + ch_out = g_malloc (npixels); + if (!ch_out) goto done; + + if (do_stretch) + fits_autostretch (norm, npixels, RENDER_STRETCH_PRESET, ch_out); + else { + for (long i = 0; i < npixels; i++) + ch_out[i] = (guchar)(norm[i] * 255.0 + 0.5); + } + + rgb = (guchar *)fits_grey_to_rgb_flipped (ch_out, width, height); + g_free (ch_out); + ch_out = NULL; + } + + if (!rgb) goto done; + + pixbuf = gdk_pixbuf_new_from_data (rgb, GDK_COLORSPACE_RGB, FALSE, + 8, width, height, width * 3, + (GdkPixbufDestroyNotify)free, NULL); + if (!pixbuf) { + free (rgb); + goto done; + } + +scale_and_return: + if (pixbuf && max_size > 0 && + (width > max_size || height > max_size)) { + double sc = MIN ((double)max_size / width, + (double)max_size / height); + int new_w = MAX (1, (int)(width * sc)); + int new_h = MAX (1, (int)(height * sc)); + + scaled = gdk_pixbuf_scale_simple (pixbuf, new_w, new_h, + GDK_INTERP_BILINEAR); + g_object_unref (pixbuf); + pixbuf = scaled; + } + } + +done: + g_free (ch_out); + g_free (norm); + g_free (raw); + if (fptr) fits_close_file (fptr, &status); + return pixbuf; +} \ No newline at end of file diff --git a/nemo-fits/fits-render.h b/nemo-fits/fits-render.h new file mode 100644 index 00000000..2c0f3cc5 --- /dev/null +++ b/nemo-fits/fits-render.h @@ -0,0 +1,27 @@ +/* fits-render.h + * + * Shared FITS image rendering pipeline — public interface. + * Opens a FITS file, reads pixel data, normalises, optionally auto-stretches, + * flips to top-down order, and returns a GdkPixbuf ready for display or saving. + * + * Used by both the nemo-fits property page extension and fits-thumbnailer. + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +/* Render a FITS file to a GdkPixbuf. + * + * path : local filesystem path to the FITS file. + * do_stretch : if TRUE, apply auto-stretch (preset 1: B=0.25, C=2.8); + * if FALSE, apply linear normalisation only. + * max_size : if either image dimension exceeds this value, the pixbuf is + * scaled down (preserving aspect ratio) so that the longer side + * equals max_size. Pass 0 to return the full native resolution. + * + * Returns a new GdkPixbuf the caller must g_object_unref(), or NULL on error. */ +GdkPixbuf *fits_render_pixbuf (const char *path, gboolean do_stretch, + int max_size); \ No newline at end of file diff --git a/nemo-fits/fits-thumbnailer.c b/nemo-fits/fits-thumbnailer.c new file mode 100644 index 00000000..47a46163 --- /dev/null +++ b/nemo-fits/fits-thumbnailer.c @@ -0,0 +1,101 @@ +/* fits-thumbnailer.c + * + * Generates thumbnails for FITS image files. + * Supports greyscale and RGB (3-plane cube) FITS images. + * Auto-stretch via fits-autostretch.c (ported from KStars/Ekos). + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include +#include + +#include "fits-render.h" + +#define MAX_SIZE 256 + +/* Read the persistent stretch setting written by the nemo-fits extension. + * Path must stay in sync with get_settings_path() in nemo-fits.c. */ +static gboolean +read_stretch_setting (void) +{ + char *path = g_build_filename (g_get_user_config_dir (), + "nemo-fits", "settings.ini", NULL); + GKeyFile *kf = g_key_file_new (); + gboolean val = TRUE; /* default: stretch enabled */ + + if (g_key_file_load_from_file (kf, path, G_KEY_FILE_NONE, NULL)) + val = g_key_file_get_boolean (kf, "nemo-fits", "autostretch", NULL); + + g_key_file_free (kf); + g_free (path); + return val; +} + +int +main (int argc, char **argv) +{ + int size = MAX_SIZE; + gboolean show_help = FALSE; + gboolean show_version = FALSE; + gboolean no_stretch = FALSE; + + GOptionEntry entries[] = { + { "size", 's', 0, G_OPTION_ARG_INT, &size, "Thumbnail size (default: 256)", "SIZE" }, + { "no-stretch", 'n', 0, G_OPTION_ARG_NONE, &no_stretch, "Disable auto-stretch", NULL }, + { "help", 'h', 0, G_OPTION_ARG_NONE, &show_help, "Show help", NULL }, + { "version", 'v', 0, G_OPTION_ARG_NONE, &show_version, "Show version", NULL }, + { NULL } + }; + + GOptionContext *context = g_option_context_new ("INPUT OUTPUT"); + g_option_context_add_main_entries (context, entries, NULL); + + GError *error = NULL; + if (!g_option_context_parse (context, &argc, &argv, &error)) { + g_printerr ("Option parsing failed: %s\n", error->message); + g_error_free (error); + g_option_context_free (context); + return 1; + } + g_option_context_free (context); + + if (show_version) { + g_print ("fits-thumbnailer 6.7.0\n"); + return 0; + } + + if (show_help || argc != 3) { + g_print ("Usage: fits-thumbnailer [OPTIONS] INPUT OUTPUT\n" + "Generate thumbnails for FITS image files\n\n" + "Options:\n" + " -s, --size SIZE Thumbnail size in pixels (default: 256)\n" + " -n, --no-stretch Disable auto-stretch (linear scale only)\n" + " -h, --help Show this help\n" + " -v, --version Show version\n"); + return show_help ? 0 : 1; + } + + if (size < 1 || size > 2048) { + g_printerr ("Invalid size: %d (must be 1-2048)\n", size); + return 1; + } + + /* Command-line flag overrides the saved setting */ + gboolean do_stretch = no_stretch ? FALSE : read_stretch_setting (); + + GdkPixbuf *pixbuf = fits_render_pixbuf (argv[1], do_stretch, size); + if (!pixbuf) + return 1; + + GError *save_error = NULL; + gboolean ok = gdk_pixbuf_save (pixbuf, argv[2], "png", &save_error, NULL); + if (!ok && save_error) { + g_printerr ("Save failed: %s\n", save_error->message); + g_error_free (save_error); + } + g_object_unref (pixbuf); + return ok ? 0 : 1; +} \ No newline at end of file diff --git a/nemo-fits/fits.thumbnailer b/nemo-fits/fits.thumbnailer new file mode 100644 index 00000000..14f7fb81 --- /dev/null +++ b/nemo-fits/fits.thumbnailer @@ -0,0 +1,4 @@ +[Thumbnailer Entry] +TryExec=fits-thumbnailer +Exec=fits-thumbnailer -s %s %i %o +MimeType=image/fits;image/x-fits;application/fits; diff --git a/nemo-fits/meson.build b/nemo-fits/meson.build new file mode 100644 index 00000000..c3786c61 --- /dev/null +++ b/nemo-fits/meson.build @@ -0,0 +1,89 @@ +project('nemo-fits', + 'c', + version: '6.7.0', + meson_version: '>=0.56.0' +) + +project_url = 'https://github.com/linuxmint/nemo-extensions' + +i18n = import('i18n') +gnome = import('gnome') + +# Dependencies +glib_dep = dependency('glib-2.0', version: '>= 2.50.0') +gtk_dep = dependency('gtk+-3.0', version: '>= 3.22.0') +gdk_pixbuf_dep = dependency('gdk-pixbuf-2.0') +nemo_ext_dep = dependency('libnemo-extension', version: '>= 3.0.0') +cfitsio_dep = dependency('cfitsio', version: '>= 3.0') +m_dep = meson.get_compiler('c').find_library('m', required: true) + +# Configuration +conf_data = configuration_data() +conf_data.set_quoted('VERSION', meson.project_version()) +conf_data.set_quoted('GETTEXT_PACKAGE', 'nemo-extensions') +conf_data.set_quoted('LOCALEDIR', join_paths(get_option('prefix'), + get_option('localedir'))) + +config_h = configure_file( + output: 'config.h', + configuration: conf_data +) + +include_dirs = include_directories('.') + +# --------------------------------------------------------------------------- +# Shared image-processing library (fits-autostretch + fits-render). +# Built as a static library so both targets get their own copy without +# requiring a separate installed shared library. +# --------------------------------------------------------------------------- + +image_lib = static_library('fits-image', + sources: [ + 'fits-autostretch.c', + 'fits-render.c', + ], + dependencies: [glib_dep, gdk_pixbuf_dep, cfitsio_dep, m_dep], + include_directories: include_dirs, + pic: true, # required because the extension is a shared module +) + +image_lib_dep = declare_dependency( + link_with: image_lib, + include_directories: include_dirs, +) + +# --------------------------------------------------------------------------- +# Nemo extension +# --------------------------------------------------------------------------- + +shared_module('nemo-fits', + sources: ['nemo-fits.c', config_h], + dependencies: [glib_dep, gtk_dep, nemo_ext_dep, cfitsio_dep, m_dep, + image_lib_dep], + include_directories: include_dirs, + install: true, + install_dir: join_paths(get_option('libdir'), 'nemo', 'extensions-3.0'), + name_prefix: 'lib', + name_suffix: 'so', +) + +# --------------------------------------------------------------------------- +# Thumbnailer executable +# --------------------------------------------------------------------------- + +executable('fits-thumbnailer', + sources: ['fits-thumbnailer.c'], + dependencies: [glib_dep, gdk_pixbuf_dep, cfitsio_dep, m_dep, + image_lib_dep], + include_directories: include_dirs, + install: true, + install_dir: get_option('bindir'), +) + +# --------------------------------------------------------------------------- +# Data files +# --------------------------------------------------------------------------- + +install_data('fits.thumbnailer', + install_dir: join_paths(get_option('datadir'), 'thumbnailers') +) \ No newline at end of file diff --git a/nemo-fits/nemo-fits.c b/nemo-fits/nemo-fits.c new file mode 100644 index 00000000..feb60c27 --- /dev/null +++ b/nemo-fits/nemo-fits.c @@ -0,0 +1,592 @@ +/* nemo-fits.c + * + * Nemo extension: FITS file property page with auto-stretch toggle. + * Supports greyscale and RGB (3-plane cube) FITS images. + * Settings are persisted via GKeyFile to ~/.config/nemo-fits/settings.ini + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include "nemo-fits.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fits-autostretch.h" +#include "fits-render.h" + +/* ------------------------------------------------------------------------- + * Settings helpers + * ------------------------------------------------------------------------- */ + +#define SETTINGS_GROUP "nemo-fits" +#define SETTINGS_KEY "autostretch" + +static char * +get_settings_path (void) +{ + return g_build_filename (g_get_user_config_dir (), + "nemo-fits", "settings.ini", NULL); +} + +static gboolean +settings_get_stretch (void) +{ + char *path = get_settings_path (); + GKeyFile *kf = g_key_file_new (); + gboolean val = TRUE; /* default: stretch enabled */ + + if (g_key_file_load_from_file (kf, path, G_KEY_FILE_NONE, NULL)) + val = g_key_file_get_boolean (kf, SETTINGS_GROUP, SETTINGS_KEY, NULL); + + g_key_file_free (kf); + g_free (path); + return val; +} + +static void +settings_set_stretch (gboolean enabled) +{ + char *path = get_settings_path (); + char *dir = g_path_get_dirname (path); + GKeyFile *kf = g_key_file_new (); + GError *error = NULL; + + g_key_file_load_from_file (kf, path, G_KEY_FILE_NONE, NULL); + g_key_file_set_boolean (kf, SETTINGS_GROUP, SETTINGS_KEY, enabled); + + g_mkdir_with_parents (dir, 0755); + + if (!g_key_file_save_to_file (kf, path, &error)) { + g_warning ("nemo-fits: could not save settings to %s: %s", + path, error->message); + g_error_free (error); + } + + g_key_file_free (kf); + g_free (dir); + g_free (path); +} + +/* ------------------------------------------------------------------------- + * GObject boilerplate + * ------------------------------------------------------------------------- */ + +static GType provider_types[1]; + +struct _NemoFitsExtension { GObject parent_slot; }; +struct _NemoFitsExtensionClass { GObjectClass parent_slot; }; + +static void property_page_provider_iface_init (NemoPropertyPageProviderIface *iface); +static void info_provider_iface_init (NemoInfoProviderIface *iface); +static void name_and_desc_provider_iface_init (NemoNameAndDescProviderIface *iface); +static void menu_provider_iface_init (NemoMenuProviderIface *iface); +static int delete_thumbnail_for_uri (const char *uri); + +G_DEFINE_DYNAMIC_TYPE_EXTENDED (NemoFitsExtension, nemo_fits_extension, G_TYPE_OBJECT, 0, + G_IMPLEMENT_INTERFACE_DYNAMIC (NEMO_TYPE_PROPERTY_PAGE_PROVIDER, + property_page_provider_iface_init) + G_IMPLEMENT_INTERFACE_DYNAMIC (NEMO_TYPE_INFO_PROVIDER, + info_provider_iface_init) + G_IMPLEMENT_INTERFACE_DYNAMIC (NEMO_TYPE_NAME_AND_DESC_PROVIDER, + name_and_desc_provider_iface_init) + G_IMPLEMENT_INTERFACE_DYNAMIC (NEMO_TYPE_MENU_PROVIDER, + menu_provider_iface_init)) + +static void nemo_fits_extension_init (NemoFitsExtension *e) { } +static void nemo_fits_extension_class_init (NemoFitsExtensionClass *c) { } +static void nemo_fits_extension_class_finalize (NemoFitsExtensionClass *c) { } + +/* ------------------------------------------------------------------------- + * FITS helpers + * ------------------------------------------------------------------------- */ + +static gboolean +is_fits_uri (const char *uri) +{ + return g_str_has_suffix (uri, ".fits") || + g_str_has_suffix (uri, ".fit") || + g_str_has_suffix (uri, ".fts") || + g_str_has_suffix (uri, ".FITS") || + g_str_has_suffix (uri, ".FIT") || + g_str_has_suffix (uri, ".FTS"); +} + +static char * +read_keyword (fitsfile *fptr, const char *key) +{ + char value[FLEN_VALUE]; + int status = 0; + return fits_read_key_str (fptr, key, value, NULL, &status) + ? NULL : g_strdup (value); +} + +/* ------------------------------------------------------------------------- + * UI helpers + * ------------------------------------------------------------------------- */ + +static void +add_row (GtkGrid *grid, int row, const char *label, const char *value) +{ + GtkWidget *lw, *vw; + char *markup; + + lw = gtk_label_new (NULL); + markup = g_markup_printf_escaped ("%s", label); + gtk_label_set_markup (GTK_LABEL (lw), markup); + g_free (markup); + gtk_widget_set_halign (lw, GTK_ALIGN_END); + gtk_grid_attach (grid, lw, 0, row, 1, 1); + + vw = gtk_label_new (value ? value : _("No Info")); + gtk_widget_set_halign (vw, GTK_ALIGN_START); + gtk_label_set_selectable (GTK_LABEL (vw), TRUE); + gtk_grid_attach (grid, vw, 1, row, 1, 1); +} + +static void +add_separator (GtkGrid *grid, int row) +{ + GtkWidget *sep = gtk_separator_new (GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_margin_top (sep, 6); + gtk_widget_set_margin_bottom (sep, 6); + gtk_grid_attach (grid, sep, 0, row, 2, 1); +} + +/* ------------------------------------------------------------------------- + * Preview state + * ------------------------------------------------------------------------- */ + +#define PREVIEW_SIZE 200 + +typedef struct { + GtkImage *preview_img; + GtkWidget *hint_label; /* shown after first toggle */ + char *fits_path; /* owned */ + NemoFileInfo *file_info; /* ref-counted, owned */ +} PreviewState; + +static void +preview_state_free (PreviewState *s) +{ + g_free (s->fits_path); + if (s->file_info) + g_object_unref (s->file_info); + g_free (s); +} + +static void +on_stretch_toggled (GObject *sw, GParamSpec *pspec, gpointer user_data) +{ + PreviewState *ps = (PreviewState *)user_data; + gboolean state = gtk_switch_get_active (GTK_SWITCH (sw)); + + settings_set_stretch (state); + + /* Invalidate the cached thumbnail so it is regenerated with the new + * stretch setting next time Nemo displays this file. */ + char *uri = g_filename_to_uri (ps->fits_path, NULL, NULL); + if (uri) { + delete_thumbnail_for_uri (uri); + g_free (uri); + } + + if (ps->file_info) + nemo_file_info_invalidate_extension_info (ps->file_info); + + /* Reveal the refresh hint the first time (and every time) the user toggles */ + if (ps->hint_label) + gtk_widget_show (ps->hint_label); + + GdkPixbuf *pb = fits_render_pixbuf (ps->fits_path, state, PREVIEW_SIZE); + if (pb) { + gtk_image_set_from_pixbuf (ps->preview_img, pb); + g_object_unref (pb); + } +} + +/* ------------------------------------------------------------------------- + * Property page + * ------------------------------------------------------------------------- */ + +static GList * +get_pages (NemoPropertyPageProvider *provider, GList *files) +{ + NemoFileInfo *file; + GtkWidget *vbox, *grid, *frame, *preview_widget; + NemoPropertyPage *page; + char *uri, *path, *value; + fitsfile *fptr = NULL; + int status = 0, naxis, bitpix, row = 0; + long naxes[10]; + gboolean stretch_on; + + if (!files || files->next) + return NULL; + + file = files->data; + uri = nemo_file_info_get_uri (file); + + if (!is_fits_uri (uri)) { + g_free (uri); + return NULL; + } + + path = g_filename_from_uri (uri, NULL, NULL); + g_free (uri); + if (!path) return NULL; + + stretch_on = settings_get_stretch (); + + vbox = gtk_box_new (GTK_ORIENTATION_VERTICAL, 6); + gtk_container_set_border_width (GTK_CONTAINER (vbox), 12); + + /* --- Preview image --- */ + GdkPixbuf *pb = fits_render_pixbuf (path, stretch_on, PREVIEW_SIZE); + preview_widget = gtk_image_new_from_pixbuf (pb); + if (pb) g_object_unref (pb); + + frame = gtk_frame_new (NULL); + gtk_frame_set_shadow_type (GTK_FRAME (frame), GTK_SHADOW_IN); + gtk_container_add (GTK_CONTAINER (frame), preview_widget); + gtk_box_pack_start (GTK_BOX (vbox), frame, FALSE, FALSE, 0); + + /* --- Stretch toggle row --- */ + { + GtkWidget *hbox = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget *lbl = gtk_label_new (_("Auto-stretch:")); + GtkWidget *sw = gtk_switch_new (); + + gtk_widget_set_halign (lbl, GTK_ALIGN_START); + gtk_widget_set_valign (sw, GTK_ALIGN_CENTER); + + gtk_box_pack_start (GTK_BOX (hbox), lbl, FALSE, FALSE, 0); + gtk_box_pack_start (GTK_BOX (hbox), sw, FALSE, FALSE, 0); + gtk_box_pack_start (GTK_BOX (vbox), hbox, FALSE, FALSE, 0); + + /* Hint label — hidden until the user actually toggles the switch */ + GtkWidget *hint = gtk_label_new (_("Thumbnails update on next refresh (F5)")); + gtk_widget_set_halign (hint, GTK_ALIGN_START); + gtk_widget_set_sensitive (hint, FALSE); + gtk_box_pack_start (GTK_BOX (vbox), hint, FALSE, FALSE, 0); + gtk_widget_set_no_show_all (hint, TRUE); /* excluded from show_all */ + + PreviewState *ps = g_new0 (PreviewState, 1); + ps->preview_img = GTK_IMAGE (preview_widget); + ps->hint_label = hint; + ps->fits_path = g_strdup (path); + ps->file_info = g_object_ref (file); + + /* Set initial state before connecting the signal so construction + * does not trigger a spurious thumbnail deletion. + * notify::active fires only on genuine user-driven changes. */ + gtk_switch_set_active (GTK_SWITCH (sw), stretch_on); + g_signal_connect_data (sw, "notify::active", + G_CALLBACK (on_stretch_toggled), ps, + (GClosureNotify)preview_state_free, 0); + } + + /* --- Metadata grid --- */ + grid = gtk_grid_new (); + gtk_grid_set_column_spacing (GTK_GRID (grid), 12); + gtk_grid_set_row_spacing (GTK_GRID (grid), 6); + gtk_box_pack_start (GTK_BOX (vbox), grid, FALSE, FALSE, 0); + + if (fits_open_file (&fptr, path, READONLY, &status)) { + add_row (GTK_GRID (grid), row++, _("Error:"), _("Could not open FITS file")); + goto done; + } + + status = 0; + if (!fits_read_key (fptr, TINT, "NAXIS", &naxis, NULL, &status) && naxis > 0) { + GString *dims = g_string_new (""); + for (int i = 1; i <= naxis && i <= 10; i++) { + char key[9]; + g_snprintf (key, sizeof (key), "NAXIS%d", i); + status = 0; + if (!fits_read_key (fptr, TLONG, key, &naxes[i-1], NULL, &status)) { + if (i > 1) g_string_append (dims, " x "); + g_string_append_printf (dims, "%ld", naxes[i-1]); + } + } + if (naxis == 2) g_string_append (dims, _(" (width x height)")); + else if (naxis == 3) g_string_append (dims, _(" (width x height x depth)")); + add_row (GTK_GRID (grid), row++, _("Dimensions:"), dims->str); + g_string_free (dims, TRUE); + + FitsImageType itype = fits_detect_image_type (fptr, naxis, naxes); + const char *mode = NULL; + switch (itype) { + case IMAGE_GREY: mode = _("Greyscale"); break; + case IMAGE_RGB: mode = _("RGB (colour cube)"); break; + case IMAGE_BAYER: mode = _("Bayer mosaic (greyscale preview)"); break; + case IMAGE_UNKNOWN: mode = _("Unknown (greyscale preview)"); break; + } + if (mode) add_row (GTK_GRID (grid), row++, _("Colour Mode:"), mode); + } + + status = 0; + { + int xbin = 0, ybin = 0; + int has_x = !fits_read_key (fptr, TINT, "XBINNING", &xbin, NULL, &status); + status = 0; + int has_y = !fits_read_key (fptr, TINT, "YBINNING", &ybin, NULL, &status); + if (has_x || has_y) { + char *bin = g_strdup_printf ("%d x %d", + has_x ? xbin : 0, + has_y ? ybin : 0); + add_row (GTK_GRID (grid), row++, _("Binning:"), bin); + g_free (bin); + } + } + + status = 0; + if (!fits_read_key (fptr, TINT, "BITPIX", &bitpix, NULL, &status)) { + const char *type = NULL; + switch (bitpix) { + case 8: type = "8-bit unsigned integer"; break; + case 16: type = "16-bit signed integer"; break; + case 32: type = "32-bit signed integer"; break; + case 64: type = "64-bit signed integer"; break; + case -32: type = "32-bit floating point"; break; + case -64: type = "64-bit floating point"; break; + } + if (type) add_row (GTK_GRID (grid), row++, _("Data Type:"), _(type)); + } + + add_separator (GTK_GRID (grid), row++); + + value = read_keyword (fptr, "OBJECT"); + add_row (GTK_GRID (grid), row++, _("Object:"), value); + g_free (value); + + value = read_keyword (fptr, "TELESCOP"); + add_row (GTK_GRID (grid), row++, _("Telescope:"), value); + g_free (value); + + value = read_keyword (fptr, "INSTRUME"); + add_row (GTK_GRID (grid), row++, _("Instrument:"), value); + g_free (value); + + value = read_keyword (fptr, "OBSERVER"); + add_row (GTK_GRID (grid), row++, _("Observer:"), value); + g_free (value); + + add_separator (GTK_GRID (grid), row++); + + value = read_keyword (fptr, "DATE-OBS"); + add_row (GTK_GRID (grid), row++, _("Date Observed:"), value); + g_free (value); + + status = 0; + { + double exp; + if (!fits_read_key (fptr, TDOUBLE, "EXPTIME", &exp, NULL, &status)) { + char *str = g_strdup_printf ("%.1f s", exp); + add_row (GTK_GRID (grid), row++, _("Exposure Time:"), str); + g_free (str); + } else { + add_row (GTK_GRID (grid), row++, _("Exposure Time:"), NULL); + } + } + + value = read_keyword (fptr, "FILTER"); + add_row (GTK_GRID (grid), row++, _("Filter:"), value); + g_free (value); + + value = read_keyword (fptr, "BAYERPAT"); + add_row (GTK_GRID (grid), row++, _("Bayer Pattern:"), value); + g_free (value); + + status = 0; + { + double gain; + if (!fits_read_key (fptr, TDOUBLE, "GAIN", &gain, NULL, &status)) { + char *str = g_strdup_printf ("%.2f", gain); + add_row (GTK_GRID (grid), row++, _("Gain:"), str); + g_free (str); + } else { + add_row (GTK_GRID (grid), row++, _("Gain:"), NULL); + } + } + + status = 0; + { + /* OFFSET is not a reserved FITS keyword; capture software commonly + * writes it as a floating-point value, so read as TDOUBLE. */ + double offset; + if (!fits_read_key (fptr, TDOUBLE, "OFFSET", &offset, NULL, &status)) { + char *str = g_strdup_printf ("%.2f", offset); + add_row (GTK_GRID (grid), row++, _("Offset:"), str); + g_free (str); + } else { + add_row (GTK_GRID (grid), row++, _("Offset:"), NULL); + } + } + + status = 0; + { + double temp; + if (!fits_read_key (fptr, TDOUBLE, "CCD-TEMP", &temp, NULL, &status)) { + char *str = g_strdup_printf ("%.2f °C", temp); + add_row (GTK_GRID (grid), row++, _("CCD Temperature:"), str); + g_free (str); + } else { + add_row (GTK_GRID (grid), row++, _("CCD Temperature:"), NULL); + } + } + + add_separator (GTK_GRID (grid), row++); + fits_close_file (fptr, &status); + +done: + g_free (path); + gtk_widget_show_all (vbox); + + page = nemo_property_page_new ("NemoFitsExtension::property_page", + gtk_label_new (_("Image")), vbox); + return g_list_append (NULL, page); +} + +/* ------------------------------------------------------------------------- + * Thumbnail cache helpers + * ------------------------------------------------------------------------- + * + * Freedesktop thumbnail spec: thumbnails are stored as PNGs named after the + * MD5 hash of the source file's canonical file:// URI, in: + * ~/.cache/thumbnails/normal/ (up to 128x128) + * ~/.cache/thumbnails/large/ (up to 256x256) + * Deleting both forces Nemo to regenerate on next directory view. + * ------------------------------------------------------------------------- */ + +static int +delete_thumbnail_for_uri (const char *uri) +{ + const char *sizes[] = { "normal", "large", NULL }; + char *hash = g_compute_checksum_for_string (G_CHECKSUM_MD5, uri, -1); + char *fname = g_strdup_printf ("%s.png", hash); + int count = 0; + + for (int i = 0; sizes[i]; i++) { + char *thumb_path = g_build_filename (g_get_user_cache_dir (), + "thumbnails", sizes[i], fname, + NULL); + if (g_unlink (thumb_path) == 0) + count++; + g_free (thumb_path); + } + + g_free (fname); + g_free (hash); + return count; +} + +/* ------------------------------------------------------------------------- + * Menu provider — single-file context menu item + * ------------------------------------------------------------------------- */ + +static void +on_regenerate_file (NemoMenuItem *item, gpointer user_data) +{ + NemoFileInfo *file = NEMO_FILE_INFO (user_data); + char *uri = nemo_file_info_get_uri (file); + + delete_thumbnail_for_uri (uri); + nemo_file_info_invalidate_extension_info (file); + + g_free (uri); +} + +static GList * +get_file_items (NemoMenuProvider *provider, GtkWidget *window, GList *files) +{ + /* Show "Regenerate FITS Thumbnail" when exactly one FITS file is selected */ + if (!files || files->next) + return NULL; + + NemoFileInfo *file = files->data; + char *uri = nemo_file_info_get_uri (file); + + if (!is_fits_uri (uri)) { + g_free (uri); + return NULL; + } + g_free (uri); + + NemoMenuItem *item = nemo_menu_item_new ( + "NemoFitsExtension::regenerate_thumbnail", + _("Regenerate FITS Thumbnail"), + _("Delete the cached thumbnail and force regeneration"), + NULL); + + g_signal_connect_data (item, "activate", + G_CALLBACK (on_regenerate_file), + g_object_ref (file), + (GClosureNotify)g_object_unref, 0); + + return g_list_append (NULL, item); +} + +/* ------------------------------------------------------------------------- + * Interface wiring + * ------------------------------------------------------------------------- */ + +static NemoOperationResult +update_file_info (NemoInfoProvider *provider, NemoFileInfo *file, + GClosure *update_complete, NemoOperationHandle **handle) +{ + return NEMO_OPERATION_COMPLETE; +} + +static void property_page_provider_iface_init (NemoPropertyPageProviderIface *iface) + { iface->get_pages = get_pages; } + +static void info_provider_iface_init (NemoInfoProviderIface *iface) + { iface->update_file_info = update_file_info; } + +static GList * +get_name_and_desc (NemoNameAndDescProvider *provider) +{ + return g_list_append (NULL, g_strdup_printf ("nemo-fits:::%s", + _("View FITS image information from the properties tab"))); +} + +static void name_and_desc_provider_iface_init (NemoNameAndDescProviderIface *iface) + { iface->get_name_and_desc = get_name_and_desc; } + +static void +menu_provider_iface_init (NemoMenuProviderIface *iface) +{ + iface->get_file_items = get_file_items; + iface->get_background_items = NULL; +} + +/* ------------------------------------------------------------------------- + * Module entry points + * ------------------------------------------------------------------------- */ + +void +nemo_module_initialize (GTypeModule *module) +{ + nemo_fits_extension_register_type (module); + provider_types[0] = NEMO_TYPE_FITS_EXTENSION; + bindtextdomain (GETTEXT_PACKAGE, LOCALEDIR); + bind_textdomain_codeset (GETTEXT_PACKAGE, "UTF-8"); +} + +void nemo_module_shutdown (void) { } + +void +nemo_module_list_types (const GType **types, int *num_types) +{ + *types = provider_types; + *num_types = G_N_ELEMENTS (provider_types); +} \ No newline at end of file diff --git a/nemo-fits/nemo-fits.h b/nemo-fits/nemo-fits.h new file mode 100644 index 00000000..77a7e92d --- /dev/null +++ b/nemo-fits/nemo-fits.h @@ -0,0 +1,19 @@ +#ifndef NEMO_FITS_EXTENSION_H +#define NEMO_FITS_EXTENSION_H + +#include + +G_BEGIN_DECLS + +#define NEMO_TYPE_FITS_EXTENSION (nemo_fits_extension_get_type ()) +#define NEMO_FITS_EXTENSION(o) (G_TYPE_CHECK_INSTANCE_CAST ((o), NEMO_TYPE_FITS_EXTENSION, NemoFitsExtension)) +#define NEMO_IS_FITS_EXTENSION(o) (G_TYPE_CHECK_INSTANCE_TYPE ((o), NEMO_TYPE_FITS_EXTENSION)) + +typedef struct _NemoFitsExtension NemoFitsExtension; +typedef struct _NemoFitsExtensionClass NemoFitsExtensionClass; + +GType nemo_fits_extension_get_type (void); + +G_END_DECLS + +#endif /* NEMO_FITS_EXTENSION_H */