Skip to content

Conversation

@saitoha
Copy link
Contributor

@saitoha saitoha commented Nov 6, 2025

Opened a PR for Issue #301.

What this adds

  • A CoreGraphics‑backed image loader for macOS.
  • Build‑time toggle via ./configure --without-coregraphics.
    By default the behavior is auto: if the flag isn’t provided, we probe the system and enable CoreGraphics when available so users don’t need to think about it. Verified on macOS (Sequoia), Linux (Debian) and MinGW: it builds cleanly and make check completes.

How it’s wired

  • The new CoreGraphics loader is appended to the end of the chicle loader chain and used strictly as a fallback when earlier loaders can’t decode a file.

Testing status

  • No automated tests yet; I’ve been verifying by hand.
  • For local verification I temporarily moved the CoreGraphics entry to the first enum value in the chicle loader list, rebuilt, and confirmed it can read: png, jpg, tiff, heif, dds, avif, pict, tga, pic, ico, bmp, jxl, animated gif, and animated avif.

Notes & known issues

The following issues occurred only when building with the CoreGraphics loader temporarily set as the highest-priority loader for testing purposes.

  • I ran into an issue where APNG animations flicker and fail to render correctly, likely due to incorrect frame disposal or blending behavior. As a workaround, APNG files are now loaded as static images. This shouldn’t be a problem when CoreGraphics is only used as a fallback.

  • WebP and GIF animations do play correctly, though the frame delay values reported by CoreGraphics seem slightly slower. chafa’s current frame timing matches Chrome and other browsers, so ideally we’d align the delays to that. However, since this loader is only meant as a fallback, I decided not to dig deeper into that difference.

** Priority corner case**:
with the AVIF loader disabled, animated AVIFs are currently picked up by the HEIF loader and render as a static image. Placing CoreGraphics above HEIF fixes it, but a more flexible loader‑priority mechanism would be preferable long‑term.

UTTypes available on my macOS
- .jpeg, .jpg, .jpe (public.jpeg)
- .png (public.png)
- .gif (com.compuserve.gif)
- .tif (com.canon.tif-raw-image)
- .dng (com.adobe.raw-image)
- .dxo (com.dxo.raw-image)
- .cr2 (com.canon.cr2-raw-image)
- .cr3 (com.canon.cr3-raw-image)
- .mos (com.leafamerica.raw-image)
- .fff (com.hasselblad.fff-raw-image)
- .3fr (com.hasselblad.3fr-raw-image)
- .nef (com.nikon.raw-image)
- .nrw (com.nikon.nrw-raw-image)
- .nefx (com.nikon.nefx-raw-image)
- .pef (com.pentax.raw-image)
- .srw (com.samsung.raw-image)
- .srf (com.sony.sr-raw-image)
- .sr2 (com.sony.sr2-raw-image)
- .arw (com.sony.arw-raw-image)
- .axr (com.sony.axr-raw-image)
- .erf (com.epson.raw-image)
- .dcr (com.kodak.raw-image)
- .tiff, .tif (public.tiff)
- .jp2, .jpf, .jpx, .j2k, .j2c (public.jpeg-2000)
- (no extension) (com.apple.atx)
- .astc (org.khronos.astc)
- .ktx (org.khronos.ktx)
- (no extension) (org.khronos.ktx2)
- .avci (public.avci)
- .jxl (public.jpeg-xl)
- .avif (public.avif)
- (no extension) (public.avis)
- .heic (public.heic)
- .heics (public.heics)
- .heif, .hif (public.heif)
- .crw (com.canon.crw-raw-image)
- .raf (com.fuji.raw-image)
- .raw (com.panasonic.raw-image)
- .rw2 (com.panasonic.rw2-raw-image)
- .raw (com.leica.raw-image)
- .rwl (com.leica.rwl-raw-image)
- .mrw (com.konicaminolta.raw-image)
- .orf (com.olympus.sr-raw-image)
- .orf (com.olympus.or-raw-image)
- .orf (com.olympus.raw-image)
- .iiq (com.phaseone.raw-image)
- .ico (com.microsoft.ico)
- .bmp, .dib (com.microsoft.bmp)
- .icns (com.apple.icns)
- .psd (com.adobe.photoshop-image)
- (no extension) (com.microsoft.cur)
- .tga (com.truevision.tga-image)
- .exr (com.ilm.openexr-image)
- .webp (org.webmproject.webp)
- .sgi (com.sgi.sgi-image)
- .pic, .hdr (public.radiance)
- .pbm, .pgm, .ppm, .pfm (public.pbm)
- .mpo (public.mpo-image)
- .pvr (public.pvr)
- .dds (com.microsoft.dds)
- .pict, .pct, .pic (com.apple.pict)

Maintenance

  • I’m aware @hpjansson doesn’t have a macOS test environment. I don’t want to increase your maintenance burden, so I’m not pushing for a merge at all costs. I’m happy to keep this on a separate branch, or I (or someone else) can maintain it as a fork if that’s preferable.

@saitoha saitoha force-pushed the coregraphics-loader branch from 1e9e76d to b1c932f Compare November 6, 2025 14:37
enabled with ./configure option --with-coregraphics (default: auto, macOS only)

Signed-off-by: Hayaki Saito <[email protected]>
@saitoha saitoha force-pushed the coregraphics-loader branch from b1c932f to 0a6796f Compare November 6, 2025 14:39
@hpjansson
Copy link
Owner

This looks very good. Thank you! I'm a little time challenged today, but will finish the review in the evening. I'll suggest a few small changes.

I think this loader can come before libheif. libheif is itself a fallback for AVIF and a couple of other formats, but CoreGraphics works better for these cases.

One question: Are you able to do a quick run in AFL or a similar fuzzer on MacOS to look for issues?

@saitoha
Copy link
Contributor Author

saitoha commented Nov 6, 2025

I’m a beginner when it comes to fuzzing tools, but I’ve decided to start testing while consulting ChatGPT along the way.

@saitoha
Copy link
Contributor Author

saitoha commented Nov 6, 2025

Made a fuzzer-driven test (see below). I'll run it for roughly some hours to collect coverage and stability data.

$ cat fuzz_chicle_coregraphics.c
#include <stdio.h>
#include <stdlib.h>
#include <glib.h>
#include "chafa.h"

/* chicle API */
#include "tools/chafa/chicle-file-mapping.h"
#include "tools/chafa/chicle-coregraphics-loader.c"

int main(int argc, char **argv)
{
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <input>\n", argv[0]);
        return 1;
    }

    const char *input_path = argv[1];
    GError *error = NULL;

    /* make file mapping */
    ChicleFileMapping *mapping = chicle_file_mapping_new(input_path);
    if (!mapping) {
        fprintf(stderr, "Failed to create file mapping for %s\n", input_path);
        return 1;
    }

    if (!chicle_file_mapping_open_now(mapping, &error)) {
        if (error) {
            fprintf(stderr, "Failed to open mapping: %s\n", error->message);
            g_error_free(error);
        }
        chicle_file_mapping_destroy(mapping);
        return 1;
    }

    /* make loader */
    ChicleCoreGraphicsLoader *loader = NULL;
    loader = g_new0(ChicleCoreGraphicsLoader, 1);

    loader->mapping = mapping;
    loader->file_data = chicle_file_mapping_get_data(mapping, &loader->file_data_len);
    if (!loader->file_data) {
        fprintf(stderr, "No data from mapping\n");
        chicle_file_mapping_destroy(mapping);
        g_free(loader);
        return 1;
    }

    loader->cf_data = CFDataCreateWithBytesNoCopy(
        kCFAllocatorDefault,
        loader->file_data,
        (CFIndex) loader->file_data_len,
        kCFAllocatorNull
    );
    if (!loader->cf_data) {
        fprintf(stderr, "Failed to create CFData\n");
        chicle_file_mapping_destroy(mapping);
        g_free(loader);
        return 1;
    }

    loader->image_source = CGImageSourceCreateWithData(loader->cf_data, NULL);
    if (!loader->image_source) {
        fprintf(stderr, "Failed to create image source\n");
        CFRelease(loader->cf_data);
        chicle_file_mapping_destroy(mapping);
        g_free(loader);
        return 1;
    }

    /* get frame */
    ChafaPixelType pixel_type;
    gint width, height, rowstride;
    const guchar *frame_data =
        chicle_coregraphics_loader_get_frame_data(loader, &pixel_type, &width, &height, &rowstride);

    if (frame_data) {
        printf("Loaded %dx%d (rowstride=%d, pixel_type=%d)\n", width, height, rowstride, pixel_type);
    } else {
        fprintf(stderr, "Failed to decode image\n");
    }

    /* clean up */
    chicle_coregraphics_loader_destroy(loader);
    return 0;
}
$ cat makefile.fuzz
BIN=./fuzz_coregraphics-1
GLIB2_CFLAGS := $(shell pkg-config --cflags glib-2.0)
GLIB2_LIBS := $(shell pkg-config --libs glib-2.0)

$(BIN): fuzz_chicle_coregraphics.c
	afl-clang-fast -I. -Ichafa -Itools/chafa $(GLIB2_CFLAGS) \
	    -o $@ \
	    tools/chafa/chafa-byte-fifo.c \
	    tools/chafa/chafa-parser.c \
	    tools/chafa/chafa-wakeup.c \
	    tools/chafa/chafa-stream-reader.c \
	    tools/chafa/chafa-stream-writer.c \
	    tools/chafa/chafa-term.c \
	    tools/chafa/chicle-util.c \
	    tools/chafa/chicle-file-mapping.c \
	    $< \
            chafa/.libs/libchafa.a \
	    $(GLIB2_LIBS) \
	    -framework CoreGraphics -framework ImageIO -framework CoreFoundation

setup:
	@mkdir -p in out; \
	colors="red green blue yellow cyan magenta orange purple black white gray"; \
	formats="jpg jpeg png gif bmp tiff webp jp2 psd exr tga hdr pbm pgm ppm pict heic avif jxl"; \
	for i in $$(seq 1 100); do \
	    color=$$(echo $$colors | awk '{srand(); r=rand(); sub(/^0\./, "", r); print $$(r % NF + 1)}'); \
	    fmt=$$(echo $$formats | awk '{srand(); r=rand(); sub(/^0\./, "", r); print $$(r % NF + 1)}'); \
	    size=$$((RANDOM%512+32))x$$((RANDOM%512+32)); \
	    noise=$$(shuf -e random uniform gaussian impulse laplacian multiplicative -n 1); \
	    distort=$$(shuf -e swirl implode wave arc -n 1); \
	    file="in/seed_$$(printf "%03d" $$i).$$fmt"; \
	    echo "→ $$file ($$size, $$color, $$noise, $$distort)"; \
	    magick  -size $$size xc:$$color -blur 0x$$((RANDOM%4)) \
	            -attenuate 0.5 +noise $$noise \
	            -swirl $$((RANDOM%360)) -rotate $$((RANDOM%90)) \
	            -set comment "Seed $$i - $$fmt - generated at $$RANDOM" \
	            $$file 2>/dev/null || echo "failed $$fmt"; \
	done

run: $(BIN) setup
	AFL_NO_FORKSRV=1 \
	AFL_SHM_FUZZ=1 \
	AFL_DISABLE_TRIM=1 \
	AFL_MAP_SIZE=262144 \
	AFL_FAST_CAL=1 \
	AFL_SKIP_CPUFREQ=1 \
	AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1 \
	AFL_AUTORESUME=1 \
	AFL_TESTCACHE_SIZE=200 \
	afl-fuzz -i in -o out -V 600 ./$< @@

@hpjansson
Copy link
Owner

Nice work on the fuzzing. If you want to re-run it in the future, you can compile chafa as normal and run it under AFL with something like this:

MY_CC=afl-clang-fast
MY_CXX=afl-clang-fast++
CC=${MY_CC} CXX=${MY_CXX} LD=${MY_CC} ./configure --enable-static --disable-shared
make clean
make -j4 CFLAGS='-Og -g'
mkdir -p fuzz/in fuzz/out
cp -a tests/data/good/* tests/data/bad/* fuzz/in/
afl-fuzz -p fast -i fuzz/in -o fuzz/out -- tools/chafa/chafa --fuzz-options --threads 1 --speed max -d 0 @@

Check before fuzzing that tools/chafa/chafa is actually an executable and not a libtool script. This will take longer since it tests all the loaders and many different backend options (due to the --fuzz-options argument, which determines the options based on input file contents).

- Remove unnecessary line break in tools/chafa/Makefile.am
- Add author name to header comment
- Raise CoreGraphics loader priority to prevent AVIF animations
  from being treated as static images by the HEIF loader
@saitoha
Copy link
Contributor Author

saitoha commented Nov 7, 2025

I ran my custom fuzz test (fuzz_coregraphics-1) for 12 hours after adding animated GIFs and images with EXIF orientation metadata (created using the commands below), but no hangs or crashes were reported.

        @find in \( -name \*.jpg -o -name \*.jpeg -o -name \*.tiff \) -exec bash -c 'exiftool -Orientation#=$$((RANDOM%8)) -overwrite_original '{} \;
        @magick -size 32x32 xc:red -delay 10 -size 64x64 xc:blue -delay 50 -loop 0 in/anim_delay.gif
        @magick -size 32x32 gradient: -delay 2 -loop 0 in/anim_unclamped.gif
        @magick -size 32x32 gradient: -delay 0 -loop 0 in/anim_zero.gif
image

I also tried running the method you suggested using the chafa CLI for about three hours, and it resulted in 11 hangs.
In this chafa CLI-based test, I forgot to move CoreGraphics to the top of the loader chain, so the test conditions might not have been meaningful.
Nonetheless, I’m attaching the hang data just in case.
collected data: hangs.zip

image

@saitoha
Copy link
Contributor Author

saitoha commented Nov 7, 2025

As a side note, when chafa hangs while loading an image and I interrupt it with Ctrl-C, the cursor remains hidden afterward — at least on my environment — due to the “CSI ? 25 l” (DECTCEM) sequence.
There may be an issue with the signal handler.

@hpjansson
Copy link
Owner

hpjansson commented Nov 7, 2025

Looks good now! I'll merge it in a minute.

As a side note, when chafa hangs while loading an image and I interrupt it with Ctrl-C, the cursor remains hidden afterward — at least on my environment — due to the “CSI ? 25 l” (DECTCEM) sequence. There may be an issue with the signal handler.

This happens because the signal handler doesn't print the sequence to re-enable the cursor itself. It just sets a global flag that's checked periodically in application loops. I did it that way because POSIX limits what a signal handler can do (e.g. no malloc()). The second ctrl-c hard interrupts the program.

Now that I think about it, though, we may be able to pregenerate the CSI sequence and issue a quick write() in the signal handler itself. I'll make an issue for it. In the meantime, --polite on will keep the cursor enabled.

I've generally ignored the hangs because I haven't been able to reproduce the ones I've found outside AFL (usually it's just something that runs very slowly when instrumented). But crashes are always serious. I'll look at the cases you found.

@hpjansson hpjansson merged commit 5d6d653 into hpjansson:master Nov 7, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants