Support PNG-format palette spec files (#1764)

This commit is contained in:
Rangi
2025-07-21 11:33:16 -04:00
committed by GitHub
parent 9ef32e405c
commit ad81c74cda
11 changed files with 211 additions and 5 deletions

View File

@@ -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

View File

@@ -9,6 +9,7 @@
#include <inttypes.h>
#include <limits.h>
#include <optional>
#include <png.h>
#include <stdint.h>
#include <stdio.h>
#include <streambuf>
@@ -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<std::filebuf *>(png_get_io_ptr(png));
std::streamsize expectedLen = length;
std::streamsize nbBytesRead = file->sgetn(reinterpret_cast<char *>(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<size_t>(file->pubseekoff(0, std::ios_base::cur))
);
}
}
static bool checkPngSwatch(std::vector<png_byte> 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<unsigned char, 8> pngHeader;
if (file.sgetn(reinterpret_cast<char *>(pngHeader.data()), pngHeader.size())
!= static_cast<std::streamsize>(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<png_byte> image(width * height * 4);
std::vector<png_bytep> 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<Rgba> &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) {

View File

@@ -185,7 +185,6 @@ public:
options.verbosePrint(Options::VERB_LOG_ACT, "Opened input file\n");
std::array<unsigned char, 8> pngHeader;
if (file->sgetn(reinterpret_cast<char *>(pngHeader.data()), pngHeader.size())
!= static_cast<std::streamsize>(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
);

1
test/gfx/full_png.flags Normal file
View File

@@ -0,0 +1 @@
-c png:full_png.pal.png

BIN
test/gfx/full_png.out.2bpp Normal file

Binary file not shown.

Binary file not shown.

BIN
test/gfx/full_png.out.pal Normal file

Binary file not shown.

Binary file not shown.

BIN
test/gfx/full_png.pal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

BIN
test/gfx/full_png.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

View File

@@ -68,8 +68,8 @@ for f in seed*.bin; do
done
for f in *.png; do
# Do not process outputs of other tests as test inputs themselves
if [[ "$f" = result.png ]]; then
# Do not process outputs or palette inputs of other tests as test inputs themselves
if [[ "$f" = result.png ]] || [[ "$f" = *.pal.png ]]; then
continue
fi