From ad81c74cdaad4748c8c0929a0ba3b6c9673519e9 Mon Sep 17 00:00:00 2001 From: Rangi <35663410+Rangi42@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:33:16 -0400 Subject: [PATCH] Support PNG-format palette spec files (#1764) --- man/rgbgfx.1 | 3 + src/gfx/pal_spec.cpp | 204 ++++++++++++++++++++++++++++++++++ src/gfx/process.cpp | 4 +- test/gfx/full_png.flags | 1 + test/gfx/full_png.out.2bpp | Bin 0 -> 384 bytes test/gfx/full_png.out.attrmap | Bin 0 -> 24 bytes test/gfx/full_png.out.pal | Bin 0 -> 64 bytes test/gfx/full_png.out.tilemap | Bin 0 -> 24 bytes test/gfx/full_png.pal.png | Bin 0 -> 310 bytes test/gfx/full_png.png | Bin 0 -> 927 bytes test/gfx/test.sh | 4 +- 11 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 test/gfx/full_png.flags create mode 100644 test/gfx/full_png.out.2bpp create mode 100644 test/gfx/full_png.out.attrmap create mode 100644 test/gfx/full_png.out.pal create mode 100644 test/gfx/full_png.out.tilemap create mode 100644 test/gfx/full_png.pal.png create mode 100644 test/gfx/full_png.png diff --git a/man/rgbgfx.1 b/man/rgbgfx.1 index b86c5861..a56efc86 100644 --- a/man/rgbgfx.1 +++ b/man/rgbgfx.1 @@ -516,6 +516,9 @@ Useful to force several images to share the same palette. Plaintext lines of hexadecimal colors in .Ql rrggbb format. +.It Cm png +An image of square color swatches, with each row defining the colors for one palette. +Color swatches can be any square size. .It Cm psp .Lk https://www.selapa.net/swatches/colors/fileformats.php#psp_pal Paint Shop Pro palette . .El diff --git a/src/gfx/pal_spec.cpp b/src/gfx/pal_spec.cpp index 09af880e..d3b65183 100644 --- a/src/gfx/pal_spec.cpp +++ b/src/gfx/pal_spec.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -578,6 +579,208 @@ static void parseGBCFile(std::filebuf &file) { } } +[[noreturn]] +static void handlePngError(png_structp, char const *msg) { + fatal("Error reading palette file: %s", msg); +} + +static void handlePngWarning(png_structp, char const *msg) { + warnx("In palette file: %s", msg); +} + +static void readPngData(png_structp png, png_bytep data, size_t length) { + std::filebuf *file = reinterpret_cast(png_get_io_ptr(png)); + std::streamsize expectedLen = length; + std::streamsize nbBytesRead = file->sgetn(reinterpret_cast(data), expectedLen); + + if (nbBytesRead != expectedLen) { + fatal( + "Error reading palette file: file too short (expected at least %zd more bytes after " + "reading %zu)", + length - nbBytesRead, + static_cast(file->pubseekoff(0, std::ios_base::cur)) + ); + } +} + +static bool checkPngSwatch(std::vector const &image, uint32_t base, uint32_t swatchSize) { + Rgba topLeft(image[base], image[base + 1], image[base + 2], image[base + 3]); + uint32_t rowFactor = swatchSize * options.nbColorsPerPal; + for (uint32_t y = 0; y < swatchSize; y++) { + for (uint32_t x = 0; x < swatchSize; x++) { + if (x == 0 && y == 0) { + continue; + } + uint32_t offset = base + (y * rowFactor + x) * 4; + Rgba pixel(image[offset], image[offset + 1], image[offset + 2], image[offset + 3]); + if (pixel != topLeft) { + return false; + } + } + } + return true; +} + +static void parsePNGFile(std::filebuf &file) { + std::array pngHeader; + if (file.sgetn(reinterpret_cast(pngHeader.data()), pngHeader.size()) + != static_cast(pngHeader.size()) // Not enough bytes? + || png_sig_cmp(pngHeader.data(), 0, pngHeader.size()) != 0) { + // LCOV_EXCL_START + error("Palette file does not appear to be a PNG palette file"); + return; + // LCOV_EXCL_STOP + } + + png_structp png = + png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, handlePngError, handlePngWarning); + if (!png) { + // LCOV_EXCL_START + error("Failed to create PNG read structure: %s", strerror(errno)); + return; + // LCOV_EXCL_STOP + } + + png_infop info = png_create_info_struct(png); + Defer destroyPng{[&] { png_destroy_read_struct(&png, &info, nullptr); }}; + if (!info) { + // LCOV_EXCL_START + error("Failed to create PNG info structure: %s", strerror(errno)); + return; + // LCOV_EXCL_STOP + } + + png_set_read_fn(png, &file, readPngData); + png_set_sig_bytes(png, pngHeader.size()); + + // Process all chunks up to but not including the image data + png_read_info(png, info); + + uint32_t width, height; + int bitDepth, colorType, interlaceType; + png_get_IHDR( + png, info, &width, &height, &bitDepth, &colorType, &interlaceType, nullptr, nullptr + ); + + png_colorp embeddedPal = nullptr; + int nbColors; + png_bytep transparencyPal = nullptr; + int nbTransparentEntries; + if (png_get_PLTE(png, info, &embeddedPal, &nbColors) != 0) { + if (png_get_tRNS(png, info, &transparencyPal, &nbTransparentEntries, nullptr)) { + assume(nbTransparentEntries <= nbColors); + } + } + + // Set up transformations to turn everything into RGBA888 for simplicity of handling + + // Convert grayscale to RGB + switch (colorType & ~PNG_COLOR_MASK_ALPHA) { + case PNG_COLOR_TYPE_GRAY: + png_set_gray_to_rgb(png); // This also converts tRNS to alpha + break; + case PNG_COLOR_TYPE_PALETTE: + png_set_palette_to_rgb(png); + break; + } + + if (png_get_valid(png, info, PNG_INFO_tRNS)) { + // If we read a tRNS chunk, convert it to alpha + png_set_tRNS_to_alpha(png); + } else if (!(colorType & PNG_COLOR_MASK_ALPHA)) { + // Otherwise, if we lack an alpha channel, default to full opacity + png_set_add_alpha(png, 0xFFFF, PNG_FILLER_AFTER); + } + + // Scale 16bpp back to 8 (we don't need all of that precision anyway) + if (bitDepth == 16) { + png_set_scale_16(png); + } else if (bitDepth < 8) { + png_set_packing(png); + } + + if (interlaceType != PNG_INTERLACE_NONE) { + png_set_interlace_handling(png); + } + + // Update `info` with the transformations + png_read_update_info(png, info); + // These shouldn't have changed + assume(png_get_image_width(png, info) == width); + assume(png_get_image_height(png, info) == height); + // These should have changed, however + assume(png_get_color_type(png, info) == PNG_COLOR_TYPE_RGBA); + assume(png_get_bit_depth(png, info) == 8); + + // Now that metadata has been read, we can process the image data + + std::vector image(width * height * 4); + std::vector rowPtrs(height); + for (uint32_t y = 0; y < height; ++y) { + rowPtrs[y] = image.data() + y * width * 4; + } + png_read_image(png, rowPtrs.data()); + + // The image width must evenly divide into a color swatch for each color per palette + if (width % options.nbColorsPerPal != 0) { + error( + "PNG palette file is %" PRIu32 "x%" PRIu32 ", which is not a multiple of %" PRIu8 + " color swatches wide", + width, + height, + options.nbColorsPerPal + ); + return; + } + + // Infer the color swatch size (width and height) from the image width + uint32_t swatchSize = width / options.nbColorsPerPal; + + // The image height must evenly divide into a color swatch for each palette + if (height % swatchSize != 0) { + error( + "PNG palette file is %" PRIu32 "x%" PRIu32 ", which is not a multiple of %" PRIu32 + " pixels high", + width, + height, + swatchSize + ); + return; + } + + // More palettes than the maximum are a warning, not an error + uint32_t nbPals = height / swatchSize; + if (nbPals > options.nbPalettes) { + warnx( + "PNG palette file contains %" PRIu32 " palette rows, but there can only be %" PRIu16 + "; ignoring extra", + nbPals, + options.nbPalettes + ); + nbPals = options.nbPalettes; + } + + options.palSpec.clear(); + + // Get each color from the top-left pixel of each swatch + uint32_t colorFactor = swatchSize * 4; + uint32_t palFactor = swatchSize * options.nbColorsPerPal; + for (uint32_t palIdx = 0; palIdx < nbPals; ++palIdx) { + options.palSpec.emplace_back(); + for (uint32_t colorIdx = 0; colorIdx < options.nbColorsPerPal; ++colorIdx) { + std::optional &color = options.palSpec.back()[colorIdx]; + uint32_t offset = (palIdx * palFactor + colorIdx) * colorFactor; + color = Rgba(image[offset], image[offset + 1], image[offset + 2], image[offset + 3]); + + // Check that each swatch is completely one color + if (!checkPngSwatch(image, offset, swatchSize)) { + error("PNG palette file uses multiple colors in one color swatch"); + return; + } + } + } +} + void parseExternalPalSpec(char const *arg) { // `fmt:path`, parse the file according to the given format @@ -596,6 +799,7 @@ void parseExternalPalSpec(char const *arg) { std::tuple{"ACT", &parseACTFile, std::ios::binary}, std::tuple{"ACO", &parseACOFile, std::ios::binary}, std::tuple{"GBC", &parseGBCFile, std::ios::binary}, + std::tuple{"PNG", &parsePNGFile, std::ios::binary}, }; auto iter = std::find_if(RANGE(parsers), [&arg, &ptr](auto const &parser) { diff --git a/src/gfx/process.cpp b/src/gfx/process.cpp index 338cdd25..4e2aca7a 100644 --- a/src/gfx/process.cpp +++ b/src/gfx/process.cpp @@ -185,7 +185,6 @@ public: options.verbosePrint(Options::VERB_LOG_ACT, "Opened input file\n"); std::array pngHeader; - if (file->sgetn(reinterpret_cast(pngHeader.data()), pngHeader.size()) != static_cast(pngHeader.size()) // Not enough bytes? || png_sig_cmp(pngHeader.data(), 0, pngHeader.size()) != 0) { @@ -215,8 +214,7 @@ public: // Process all chunks up to but not including the image data png_read_info(png, info); - int bitDepth, interlaceType; //, compressionType, filterMethod; - + int bitDepth, interlaceType; png_get_IHDR( png, info, &width, &height, &bitDepth, &colorType, &interlaceType, nullptr, nullptr ); diff --git a/test/gfx/full_png.flags b/test/gfx/full_png.flags new file mode 100644 index 00000000..14222dab --- /dev/null +++ b/test/gfx/full_png.flags @@ -0,0 +1 @@ +-c png:full_png.pal.png diff --git a/test/gfx/full_png.out.2bpp b/test/gfx/full_png.out.2bpp new file mode 100644 index 0000000000000000000000000000000000000000..f961019183351b5194d7018ff9f050855413e6a4 GIT binary patch literal 384 zcmZ9IF$zQ>5JLr9!K)0UUGVCHZNRGtwt_qP{Mlk-5gdl(sTC^+2idYME{5AX9~i*G z;^A4gEIj5&NQsHr)k`lG6>YW|e(7iXd0o2=4V;|5S|eL|$`2Oxizl+xqx>fa7e7Du zNO#^T1;28X5k2ZumUfVlJ3F`Q|DHbSQ$`s^C+W-In literal 0 HcmV?d00001 diff --git a/test/gfx/full_png.out.tilemap b/test/gfx/full_png.out.tilemap new file mode 100644 index 0000000000000000000000000000000000000000..6cf04740a9306bb0d3548bd0d741c6f1f2ee9ba8 GIT binary patch literal 24 fcmZQzWMXDvWn<^yMC+6cQE@6%z*l2^0Yp literal 0 HcmV?d00001 diff --git a/test/gfx/full_png.pal.png b/test/gfx/full_png.pal.png new file mode 100644 index 0000000000000000000000000000000000000000..91480ad73197ac4e7691e9be73494b8bd27d1b70 GIT binary patch literal 310 zcmeAS@N?(olHy`uVBq!ia0vp^3P9|@!3HF&`%2dVDVAa<&k$BtRz|NAFW&?CZ#`Wc zLoyoQ&QKIOWFX*b?!#g+L1v-QX~78(HcWjhI6e}D4R!S;D)!KgfvAd4r zN3Gsb&PSJu`GD%U#RHx{@2_q=T&%RM!tY5aZ{;TzprKqZN-YjI>BuO4U))#|{NX>) zrVRy*x}~h#vwpLFI`~epzMS#X6HcH3B0rdvd>ud7XRJ^LdYr-2)z4*}Q$iB}wyShA literal 0 HcmV?d00001 diff --git a/test/gfx/full_png.png b/test/gfx/full_png.png new file mode 100644 index 0000000000000000000000000000000000000000..de5a5849a134e1a9f529236f19e0b8df6d26d8ea GIT binary patch literal 927 zcmV;Q17Q4#P)J3AOkK}W2aOewh`B+;b8RYA_c8Tf$hh1I$#ysd0DOXo3i0qQ z0&qgyWkSdcSxMM0T6#480&qfJGH%rXoRD|*=Q8(ok(XqjfuPDSj-EmQXEtsoY*zED z8wh#XxNJX{%K-q=G_{^fG_OCMTI2m0nQoN>4uoVlkPp<$0B*s%iY6SwaJrSKU5FqCZ_Xmf>xUpiV4k!{h~u+bY` zJN~Y(e+V86ejK6t~0?O8S!JIeq4CG#$PwDwU!SV9A%}iOD zGcK)4%(S%~ZM$ePuP@WKXS8i!Ca=zHpOGA+P$jX>*=$3A`GeQZaiFO%t44V&@Wg8OHlfji%WdbJ7wDAve;uOCFZg9bpkk^|U&q#HG7l&-BdGJ& z=t5e^&*wzE*{oRa5%RM|WOVi?ZNB!1{N5mcdp;nWcSRkWuU|+^rbL%aY(A*xjV=)r zLSA|vwDA)Yp`|ZJWW+^&SYqv!c?OQvs4`rjFdb6gME3xI*>-ce8bG6C;<(AY>cAcz zxK6#XY9qI5od>Ly