Factor out a single PNG-reading function to encapsulate the libpng API (#1765)

This commit is contained in:
Rangi
2025-07-23 15:53:33 -04:00
committed by GitHub
parent 2ce4cdbff6
commit 7e151f16c3
19 changed files with 395 additions and 543 deletions

View File

@@ -105,6 +105,7 @@ rgbgfx_obj := \
src/gfx/pal_packing.o \
src/gfx/pal_sorting.o \
src/gfx/pal_spec.o \
src/gfx/png.o \
src/gfx/process.o \
src/gfx/proto_palette.o \
src/gfx/reverse.o \
@@ -152,6 +153,8 @@ src/gfx/pal_sorting.o: src/gfx/pal_sorting.cpp
$Q${CXX} ${REALCXXFLAGS} ${PNGCFLAGS} -c -o $@ $<
src/gfx/pal_spec.o: src/gfx/pal_spec.cpp
$Q${CXX} ${REALCXXFLAGS} ${PNGCFLAGS} -c -o $@ $<
src/gfx/png.o: src/gfx/png.cpp
$Q${CXX} ${REALCXXFLAGS} ${PNGCFLAGS} -c -o $@ $<
src/gfx/process.o: src/gfx/process.cpp
$Q${CXX} ${REALCXXFLAGS} ${PNGCFLAGS} -c -o $@ $<
src/gfx/proto_palette.o: src/gfx/proto_palette.cpp

View File

@@ -5,7 +5,6 @@
#include <array>
#include <optional>
#include <png.h>
#include <vector>
#include "gfx/rgba.hpp"
@@ -16,13 +15,7 @@ static constexpr size_t NB_COLOR_SLOTS = (1 << (5 * 3)) + 1;
struct Palette;
void sortIndexed(
std::vector<Palette> &palettes,
int palSize,
png_color const *palRGB,
int palAlphaSize,
png_byte *palAlpha
);
void sortIndexed(std::vector<Palette> &palettes, std::vector<Rgba> const &embPal);
void sortGrayscale(
std::vector<Palette> &palettes, std::array<std::optional<Rgba>, NB_COLOR_SLOTS> const &colors
);

21
include/gfx/png.hpp Normal file
View File

@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
#ifndef RGBDS_GFX_PNG_HPP
#define RGBDS_GFX_PNG_HPP
#include <fstream>
#include <stdint.h>
#include <vector>
#include "gfx/rgba.hpp"
struct Png {
uint32_t width, height;
std::vector<Rgba> pixels{};
std::vector<Rgba> palette{};
Png() {}
Png(char const *filename, std::streambuf &file);
};
#endif // RGBDS_GFX_PNG_HPP

View File

@@ -17,16 +17,16 @@ struct Rgba {
explicit constexpr Rgba(uint32_t rgba = 0)
: red(rgba >> 24), green(rgba >> 16), blue(rgba >> 8), alpha(rgba) {}
static constexpr Rgba fromCGBColor(uint16_t cgbColor) {
constexpr auto _5to8 = [](uint8_t fiveBpp) -> uint8_t {
fiveBpp &= 0b11111; // For caller's convenience
return fiveBpp << 3 | fiveBpp >> 2;
static constexpr Rgba fromCGBColor(uint16_t color) {
constexpr auto _5to8 = [](uint8_t channel) -> uint8_t {
channel &= 0b11111; // For caller's convenience
return channel << 3 | channel >> 2;
};
return {
_5to8(cgbColor),
_5to8(cgbColor >> 5),
_5to8(cgbColor >> 10),
static_cast<uint8_t>(cgbColor & 0x8000 ? 0x00 : 0xFF),
_5to8(color),
_5to8(color >> 5),
_5to8(color >> 10),
static_cast<uint8_t>(color & 0x8000 ? 0x00 : 0xFF),
};
}

View File

@@ -79,6 +79,7 @@ set(rgbgfx_src
"gfx/pal_packing.cpp"
"gfx/pal_sorting.cpp"
"gfx/pal_spec.cpp"
"gfx/png.cpp"
"gfx/process.cpp"
"gfx/proto_palette.cpp"
"gfx/reverse.cpp"

View File

@@ -8,27 +8,14 @@
#include "gfx/main.hpp"
void sortIndexed(
std::vector<Palette> &palettes,
int palSize,
png_color const *palRGB,
int palAlphaSize,
png_byte *palAlpha
) {
void sortIndexed(std::vector<Palette> &palettes, std::vector<Rgba> const &embPal) {
options.verbosePrint(Options::VERB_LOG_ACT, "Sorting palettes using embedded palette...\n");
auto pngToRgb = [&palRGB, &palAlphaSize, &palAlpha](int index) {
png_color const &c = palRGB[index];
return Rgba(
c.red, c.green, c.blue, palAlpha && index < palAlphaSize ? palAlpha[index] : 0xFF
);
};
for (Palette &pal : palettes) {
std::sort(RANGE(pal), [&](uint16_t lhs, uint16_t rhs) {
// Iterate through the PNG's palette, looking for either of the two
for (int i = 0; i < palSize; ++i) {
uint16_t color = pngToRgb(i).cgbColor();
for (Rgba const &rgba : embPal) {
uint16_t color = rgba.cgbColor();
if (color == Rgba::transparent) {
continue;
}

View File

@@ -9,7 +9,6 @@
#include <inttypes.h>
#include <limits.h>
#include <optional>
#include <png.h>
#include <stdint.h>
#include <stdio.h>
#include <streambuf>
@@ -23,6 +22,7 @@
#include "platform.hpp"
#include "gfx/main.hpp"
#include "gfx/png.hpp"
#include "gfx/warning.hpp"
using namespace std::string_view_literals;
@@ -265,7 +265,7 @@ static std::optional<Rgba> parseColor(std::string const &str, size_t &n, uint16_
return std::optional<Rgba>{Rgba(*r, *g, *b, 0xFF)};
}
static void parsePSPFile(std::filebuf &file) {
static void parsePSPFile(char const *, std::filebuf &file) {
// https://www.selapa.net/swatches/colors/fileformats.php#psp_pal
std::string line;
@@ -328,7 +328,7 @@ static void parsePSPFile(std::filebuf &file) {
}
}
static void parseGPLFile(std::filebuf &file) {
static void parseGPLFile(char const *, std::filebuf &file) {
// https://gitlab.gnome.org/GNOME/gimp/-/blob/gimp-2-10/app/core/gimppalette-load.c#L39
std::string line;
@@ -383,7 +383,7 @@ static void parseGPLFile(std::filebuf &file) {
}
}
static void parseHEXFile(std::filebuf &file) {
static void parseHEXFile(char const *, std::filebuf &file) {
// https://lospec.com/palette-list/tag/gbc
uint16_t nbColors = 0;
@@ -430,7 +430,7 @@ static void parseHEXFile(std::filebuf &file) {
}
}
static void parseACTFile(std::filebuf &file) {
static void parseACTFile(char const *, std::filebuf &file) {
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577411_pgfId-1070626
std::array<char, 772> buf{};
@@ -482,7 +482,7 @@ static void parseACTFile(std::filebuf &file) {
}
}
static void parseACOFile(std::filebuf &file) {
static void parseACOFile(char const *, std::filebuf &file) {
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577411_pgfId-1055819
char buf[10];
@@ -551,7 +551,7 @@ static void parseACOFile(std::filebuf &file) {
}
}
static void parseGBCFile(std::filebuf &file) {
static void parseGBCFile(char const *, std::filebuf &file) {
// This only needs to be able to read back files generated by `rgbgfx -p`
options.palSpec.clear();
@@ -579,41 +579,15 @@ 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;
static bool checkPngSwatch(std::vector<Rgba> const &pixels, uint32_t base, uint32_t swatchSize) {
for (uint32_t y = 0; y < swatchSize; y++) {
uint32_t yOffset = y * swatchSize * options.nbColorsPerPal + base;
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) {
if (pixels[yOffset + x] != pixels[base]) {
return false;
}
}
@@ -621,135 +595,38 @@ static bool checkPngSwatch(std::vector<png_byte> const &image, uint32_t base, ui
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());
static void parsePNGFile(char const *filename, std::filebuf &file) {
Png png{filename, file};
// The image width must evenly divide into a color swatch for each color per palette
if (width % options.nbColorsPerPal != 0) {
if (png.width % options.nbColorsPerPal != 0) {
error(
"PNG palette file is %" PRIu32 "x%" PRIu32 ", which is not a multiple of %" PRIu8
" color swatches wide",
width,
height,
png.width,
png.height,
options.nbColorsPerPal
);
return;
}
// Infer the color swatch size (width and height) from the image width
uint32_t swatchSize = width / options.nbColorsPerPal;
uint32_t swatchSize = png.width / options.nbColorsPerPal;
// The image height must evenly divide into a color swatch for each palette
if (height % swatchSize != 0) {
if (png.height % swatchSize != 0) {
error(
"PNG palette file is %" PRIu32 "x%" PRIu32 ", which is not a multiple of %" PRIu32
" pixels high",
width,
height,
png.width,
png.height,
swatchSize
);
return;
}
// More palettes than the maximum are a warning, not an error
uint32_t nbPals = height / swatchSize;
uint32_t nbPals = png.height / swatchSize;
if (nbPals > options.nbPalettes) {
warnx(
"PNG palette file contains %" PRIu32 " palette rows, but there can only be %" PRIu16
@@ -763,17 +640,16 @@ static void parsePNGFile(std::filebuf &file) {
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) {
for (uint32_t y = 0; y < nbPals; ++y) {
uint32_t yOffset = y * swatchSize * swatchSize * options.nbColorsPerPal;
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]);
for (uint32_t x = 0; x < options.nbColorsPerPal; ++x) {
uint32_t offset = yOffset + x * swatchSize;
options.palSpec.back()[x] = png.pixels[offset];
// Check that each swatch is completely one color
if (!checkPngSwatch(image, offset, swatchSize)) {
if (!checkPngSwatch(png.pixels, offset, swatchSize)) {
error("PNG palette file uses multiple colors in one color swatch");
return;
}
@@ -821,7 +697,7 @@ void parseExternalPalSpec(char const *arg) {
return;
}
std::get<1> (*iter)(file);
std::get<1> (*iter)(path, file);
}
void parseDmgPalSpec(char const * const rawArg) {

216
src/gfx/png.cpp Normal file
View File

@@ -0,0 +1,216 @@
// SPDX-License-Identifier: MIT
#include "gfx/png.hpp"
#include <png.h>
#include "gfx/main.hpp"
#include "gfx/rgba.hpp"
#include "gfx/warning.hpp"
struct Input {
char const *filename;
std::streambuf &file;
Input(char const *filename_, std::streambuf &file_) : filename(filename_), file(file_) {}
};
[[noreturn]]
static void handleError(png_structp png, char const *msg) {
Input const &input = *reinterpret_cast<Input *>(png_get_error_ptr(png));
fatal("Error reading PNG image (\"%s\"): %s", input.filename, msg);
}
static void handleWarning(png_structp png, char const *msg) {
Input const &input = *reinterpret_cast<Input *>(png_get_error_ptr(png));
warnx("In PNG image (\"%s\"): %s", input.filename, msg);
}
static void readData(png_structp png, png_bytep data, size_t length) {
Input &input = *reinterpret_cast<Input *>(png_get_io_ptr(png));
std::streamsize expectedLen = length;
std::streamsize nbBytesRead = input.file.sgetn(reinterpret_cast<char *>(data), expectedLen);
if (nbBytesRead != expectedLen) {
fatal(
"Error reading PNG image (\"%s\"): file too short (expected at least %zd more "
"bytes after reading %zu)",
input.filename,
length - nbBytesRead,
static_cast<size_t>(input.file.pubseekoff(0, std::ios_base::cur))
);
}
}
Png::Png(char const *filename, std::streambuf &file) {
Input input(filename, file);
options.verbosePrint(Options::VERB_LOG_ACT, "Reading PNG file \"%s\"\n", input.filename);
std::array<unsigned char, 8> pngHeader;
if (input.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) {
fatal("PNG file (\"%s\") is not a valid PNG image!", input.filename); // LCOV_EXCL_LINE
}
options.verbosePrint(Options::VERB_INTERM, "PNG header signature is OK\n");
png_structp png = png_create_read_struct(
PNG_LIBPNG_VER_STRING, static_cast<png_voidp>(&input), handleError, handleWarning
);
if (!png) {
fatal("Failed to create PNG read structure: %s", strerror(errno)); // LCOV_EXCL_LINE
}
png_infop info = png_create_info_struct(png);
Defer destroyPng{[&] { png_destroy_read_struct(&png, info ? &info : nullptr, nullptr); }};
if (!info) {
fatal("Failed to create PNG info structure: %s", strerror(errno)); // LCOV_EXCL_LINE
}
png_set_read_fn(png, &input, readData);
png_set_sig_bytes(png, pngHeader.size());
// Process all chunks up to but not including the image data
png_read_info(png, info);
int bitDepth, colorType, interlaceType;
png_get_IHDR(
png, info, &width, &height, &bitDepth, &colorType, &interlaceType, nullptr, nullptr
);
pixels.resize(static_cast<size_t>(width) * static_cast<size_t>(height));
auto colorTypeName = [](int type) {
switch (type) {
case PNG_COLOR_TYPE_GRAY:
return "grayscale";
case PNG_COLOR_TYPE_GRAY_ALPHA:
return "grayscale + alpha";
case PNG_COLOR_TYPE_PALETTE:
return "palette";
case PNG_COLOR_TYPE_RGB:
return "RGB";
case PNG_COLOR_TYPE_RGB_ALPHA:
return "RGB + alpha";
default:
return "unknown color type";
}
};
auto interlaceTypeName = [](int type) {
switch (type) {
case PNG_INTERLACE_NONE:
return "not interlaced";
case PNG_INTERLACE_ADAM7:
return "interlaced (Adam7)";
default:
return "unknown interlace type";
}
};
options.verbosePrint(
Options::VERB_INTERM,
"PNG image: %" PRIu32 "x%" PRIu32 " pixels, %dbpp %s, %s\n",
width,
height,
bitDepth,
colorTypeName(colorType),
interlaceTypeName(interlaceType)
);
int nbColors = 0;
png_colorp embeddedPal = nullptr;
if (png_get_PLTE(png, info, &embeddedPal, &nbColors) != 0) {
int nbTransparentEntries = 0;
png_bytep transparencyPal = nullptr;
if (png_get_tRNS(png, info, &transparencyPal, &nbTransparentEntries, nullptr)) {
assume(nbTransparentEntries <= nbColors);
}
for (int i = 0; i < nbColors; ++i) {
png_color const &color = embeddedPal[i];
palette.emplace_back(
color.red,
color.green,
color.blue,
transparencyPal && i < nbTransparentEntries ? transparencyPal[i] : 0xFF
);
}
options.verbosePrint(
Options::VERB_INTERM, "Embedded PNG palette has %d colors: [", nbColors
);
for (int i = 0; i < nbColors; ++i) {
if (i > 0) {
options.verbosePrint(Options::VERB_INTERM, ", ");
}
options.verbosePrint(Options::VERB_INTERM, "#%08x", palette[i].toCSS());
}
options.verbosePrint(Options::VERB_INTERM, "]\n");
} else {
options.verbosePrint(Options::VERB_INTERM, "No embedded PNG palette\n");
}
// Set up transformations to turn everything into RGBA8888 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);
}
// Deinterlace rows so they can trivially be read in order
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 read 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());
// We don't care about chunks after the image data (comments, etc.)
png_read_end(png, nullptr);
// Finally, process the image data from RGBA8888 bytes into `Rgba` colors
for (uint32_t y = 0; y < height; ++y) {
for (uint32_t x = 0; x < width; ++x) {
uint32_t idx = y * width + x;
uint32_t off = idx * 4;
pixels[idx] = Rgba(image[off], image[off + 1], image[off + 2], image[off + 3]);
}
}
}

View File

@@ -23,6 +23,7 @@
#include "gfx/main.hpp"
#include "gfx/pal_packing.hpp"
#include "gfx/pal_sorting.hpp"
#include "gfx/png.hpp"
#include "gfx/proto_palette.hpp"
#include "gfx/warning.hpp"
@@ -41,9 +42,10 @@ public:
// color), then the other color is returned. Otherwise, `nullptr` is returned.
[[nodiscard]]
Rgba const *registerColor(Rgba const &rgba) {
std::optional<Rgba> &slot = _colors[rgba.cgbColor()];
uint16_t color = rgba.cgbColor();
std::optional<Rgba> &slot = _colors[color];
if (rgba.cgbColor() == Rgba::transparent && !isBgColorTransparent()) {
if (color == Rgba::transparent && !isBgColorTransparent()) {
options.hasTransparentPixels = true;
}
@@ -67,70 +69,12 @@ public:
auto end() const { return _colors.end(); }
};
class Png {
std::string const &path;
File file{};
png_structp png = nullptr;
png_infop info = nullptr;
struct Image {
Png png{};
ImagePalette colors{};
// These are cached for speed
uint32_t width, height;
std::vector<Rgba> pixels;
ImagePalette colors;
int colorType;
int nbColors;
png_colorp embeddedPal = nullptr;
int nbTransparentEntries;
png_bytep transparencyPal = nullptr;
[[noreturn]]
static void handleError(png_structp png, char const *msg) {
Png *self = reinterpret_cast<Png *>(png_get_error_ptr(png));
fatal("Error reading input image (\"%s\"): %s", self->c_str(), msg);
}
static void handleWarning(png_structp png, char const *msg) {
Png *self = reinterpret_cast<Png *>(png_get_error_ptr(png));
warnx("In input image (\"%s\"): %s", self->c_str(), msg);
}
static void readData(png_structp png, png_bytep data, size_t length) {
Png *self = reinterpret_cast<Png *>(png_get_io_ptr(png));
std::streamsize expectedLen = length;
std::streamsize nbBytesRead =
self->file->sgetn(reinterpret_cast<char *>(data), expectedLen);
if (nbBytesRead != expectedLen) {
fatal(
"Error reading input image (\"%s\"): file too short (expected at least %zd more "
"bytes after reading %zu)",
self->c_str(),
length - nbBytesRead,
static_cast<size_t>(self->file->pubseekoff(0, std::ios_base::cur))
);
}
}
public:
ImagePalette const &getColors() const { return colors; }
int getColorType() const { return colorType; }
std::tuple<int, png_const_colorp, int, png_bytep> getEmbeddedPal() const {
return {nbColors, embeddedPal, nbTransparentEntries, transparencyPal};
}
uint32_t getWidth() const { return width; }
uint32_t getHeight() const { return height; }
Rgba &pixel(uint32_t x, uint32_t y) { return pixels[y * width + x]; }
Rgba const &pixel(uint32_t x, uint32_t y) const { return pixels[y * width + x]; }
char const *c_str() const { return file.c_str(path); }
Rgba &pixel(uint32_t x, uint32_t y) { return png.pixels[y * png.width + x]; }
Rgba const &pixel(uint32_t x, uint32_t y) const { return png.pixels[y * png.width + x]; }
bool isSuitableForGrayscale() const {
// Check that all of the grays don't fall into the same "bin"
@@ -170,62 +114,22 @@ public:
return true;
}
// Reads a PNG and notes all of its colors
//
// This code is more complicated than strictly necessary, but that's because of the API
// being used: the "high-level" interface doesn't provide all the transformations we need,
// so we use the "lower-level" one instead.
// We also use that occasion to only read the PNG one line at a time, since we store all of
// the pixel data in `pixels`, which saves on memory allocations.
explicit Png(std::string const &filePath) : path(filePath), colors() {
if (file.open(path, std::ios_base::in | std::ios_base::binary) == nullptr) {
fatal("Failed to open input image (\"%s\"): %s", file.c_str(path), strerror(errno));
explicit Image(std::string const &path) {
File input;
if (input.open(path, std::ios_base::in | std::ios_base::binary) == nullptr) {
fatal("Failed to open input image (\"%s\"): %s", input.c_str(path), strerror(errno));
}
options.verbosePrint(Options::VERB_LOG_ACT, "Opened input file\n");
png = Png(input.c_str(path), *input);
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) {
fatal("Input file (\"%s\") is not a PNG image!", file.c_str(path));
// Validate input slice
if (options.inputSlice.width == 0 && png.width % 8 != 0) {
fatal("Image width (%" PRIu32 " pixels) is not a multiple of 8!", png.width);
}
options.verbosePrint(Options::VERB_INTERM, "PNG header signature is OK\n");
png = png_create_read_struct(
PNG_LIBPNG_VER_STRING, static_cast<png_voidp>(this), handleError, handleWarning
);
if (!png) {
fatal("Failed to create PNG read structure: %s", strerror(errno)); // LCOV_EXCL_LINE
if (options.inputSlice.height == 0 && png.height % 8 != 0) {
fatal("Image height (%" PRIu32 " pixels) is not a multiple of 8!", png.height);
}
info = png_create_info_struct(png);
if (!info) {
// LCOV_EXCL_START
png_destroy_read_struct(&png, nullptr, nullptr);
fatal("Failed to create PNG info structure: %s", strerror(errno));
// LCOV_EXCL_STOP
}
png_set_read_fn(png, this, readData);
png_set_sig_bytes(png, pngHeader.size());
// Process all chunks up to but not including the image data
png_read_info(png, info);
int bitDepth, interlaceType;
png_get_IHDR(
png, info, &width, &height, &bitDepth, &colorType, &interlaceType, nullptr, nullptr
);
if (options.inputSlice.width == 0 && width % 8 != 0) {
fatal("Image width (%" PRIu32 " pixels) is not a multiple of 8!", width);
}
if (options.inputSlice.height == 0 && height % 8 != 0) {
fatal("Image height (%" PRIu32 " pixels) is not a multiple of 8!", height);
}
if (options.inputSlice.right() > width || options.inputSlice.bottom() > height) {
if (options.inputSlice.right() > png.width || options.inputSlice.bottom() > png.height) {
error(
"Image slice ((%" PRIu16 ", %" PRIu16 ") to (%" PRIu32 ", %" PRIu32
")) is outside the image bounds (%" PRIu32 "x%" PRIu32 ")!",
@@ -233,8 +137,8 @@ public:
options.inputSlice.top,
options.inputSlice.right(),
options.inputSlice.bottom(),
width,
height
png.width,
png.height
);
if (options.inputSlice.width % 8 == 0 && options.inputSlice.height % 8 == 0) {
fprintf(
@@ -250,111 +154,6 @@ public:
giveUp();
}
pixels.resize(static_cast<size_t>(width) * static_cast<size_t>(height));
auto colorTypeName = [this]() {
switch (colorType) {
case PNG_COLOR_TYPE_GRAY:
return "grayscale";
case PNG_COLOR_TYPE_GRAY_ALPHA:
return "grayscale + alpha";
case PNG_COLOR_TYPE_PALETTE:
return "palette";
case PNG_COLOR_TYPE_RGB:
return "RGB";
case PNG_COLOR_TYPE_RGB_ALPHA:
return "RGB + alpha";
default:
fatal("Unknown color type %d", colorType);
}
};
auto interlaceTypeName = [&interlaceType]() {
switch (interlaceType) {
case PNG_INTERLACE_NONE:
return "not interlaced";
case PNG_INTERLACE_ADAM7:
return "interlaced (Adam7)";
default:
fatal("Unknown interlace type %d", interlaceType);
}
};
options.verbosePrint(
Options::VERB_INTERM,
"Input image: %" PRIu32 "x%" PRIu32 " pixels, %dbpp %s, %s\n",
width,
height,
bitDepth,
colorTypeName(),
interlaceTypeName()
);
if (png_get_PLTE(png, info, &embeddedPal, &nbColors) != 0) {
if (png_get_tRNS(png, info, &transparencyPal, &nbTransparentEntries, nullptr)) {
assume(nbTransparentEntries <= nbColors);
}
options.verbosePrint(
Options::VERB_INTERM, "Embedded palette has %d colors: [", nbColors
);
for (int i = 0; i < nbColors; ++i) {
png_color const &color = embeddedPal[i];
options.verbosePrint(
Options::VERB_INTERM,
"#%02x%02x%02x%02x%s",
color.red,
color.green,
color.blue,
transparencyPal && i < nbTransparentEntries ? transparencyPal[i] : 0xFF,
i != nbColors - 1 ? ", " : "]\n"
);
}
} else {
options.verbosePrint(Options::VERB_INTERM, "No embedded palette\n");
}
// 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);
}
// Do NOT call `png_set_interlace_handling`. We want to expand the rows ourselves.
// 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
size_t nbRowBytes = png_get_rowbytes(png, info);
assume(nbRowBytes != 0);
std::vector<png_byte> row(nbRowBytes);
// Holds known-conflicting color pairs to avoid warning about them twice.
// We don't need to worry about transitivity, as ImagePalette slots are immutable once
// assigned, and conflicts always occur between that and another color.
@@ -363,9 +162,13 @@ public:
// Holds colors whose alpha value is ambiguous
std::vector<uint32_t> indeterminates;
// Assign a color to the given position, and register it in the image palette as well
auto assignColor = [&](png_uint_32 x, png_uint_32 y, Rgba &&color) {
if (!color.isTransparent() && !color.isOpaque()) {
// Register colors from image
for (uint32_t y = 0; y < png.height; ++y) {
for (uint32_t x = 0; x < png.width; ++x) {
Rgba const &color = pixel(x, y);
// Assign a color to the given position, and register it in the image palette
if (color.isTransparent() == color.isOpaque()) {
uint32_t css = color.toCSS();
if (std::find(RANGE(indeterminates), css) == indeterminates.end()) {
error(
@@ -396,70 +199,30 @@ public:
conflicts.emplace_back(conflicting);
}
}
pixel(x, y) = color;
};
if (interlaceType == PNG_INTERLACE_NONE) {
for (png_uint_32 y = 0; y < height; ++y) {
png_bytep ptr = row.data();
png_read_row(png, ptr, nullptr);
for (png_uint_32 x = 0; x < width; ++x) {
assignColor(x, y, Rgba(ptr[0], ptr[1], ptr[2], ptr[3]));
ptr += 4;
}
}
} else {
assume(interlaceType == PNG_INTERLACE_ADAM7);
// For interlace to work properly, we must read the image `nbPasses` times
for (int pass = 0; pass < PNG_INTERLACE_ADAM7_PASSES; ++pass) {
// The interlacing pass must be skipped if its width or height is reported as zero
if (PNG_PASS_COLS(width, pass) == 0 || PNG_PASS_ROWS(height, pass) == 0) {
continue;
}
png_uint_32 xStep = 1u << PNG_PASS_COL_SHIFT(pass);
png_uint_32 yStep = 1u << PNG_PASS_ROW_SHIFT(pass);
for (png_uint_32 y = PNG_PASS_START_ROW(pass); y < height; y += yStep) {
png_bytep ptr = row.data();
png_read_row(png, ptr, nullptr);
for (png_uint_32 x = PNG_PASS_START_COL(pass); x < width; x += xStep) {
assignColor(x, y, Rgba(ptr[0], ptr[1], ptr[2], ptr[3]));
ptr += 4;
}
}
}
}
// We don't care about chunks after the image data (comments, etc.)
png_read_end(png, nullptr);
}
~Png() { png_destroy_read_struct(&png, &info, nullptr); }
class TilesVisitor {
Png const &_png;
Image const &_image;
bool const _columnMajor;
uint32_t const _width, _height;
uint32_t const _limit = _columnMajor ? _height : _width;
public:
TilesVisitor(Png const &png, bool columnMajor, uint32_t width, uint32_t height)
: _png(png), _columnMajor(columnMajor), _width(width), _height(height) {}
TilesVisitor(Image const &image, bool columnMajor, uint32_t width, uint32_t height)
: _image(image), _columnMajor(columnMajor), _width(width), _height(height) {}
class Tile {
Png const &_png;
Image const &_image;
public:
uint32_t const x, y;
Tile(Png const &png, uint32_t x_, uint32_t y_) : _png(png), x(x_), y(y_) {}
Tile(Image const &image, uint32_t x_, uint32_t y_) : _image(image), x(x_), y(y_) {}
Rgba pixel(uint32_t xOfs, uint32_t yOfs) const {
return _png.pixel(x + xOfs, y + yOfs);
return _image.pixel(x + xOfs, y + yOfs);
}
};
@@ -473,7 +236,7 @@ public:
return {x + options.inputSlice.left, y + options.inputSlice.top};
}
Tile operator*() const {
return {parent._png, x + options.inputSlice.left, y + options.inputSlice.top};
return {parent._image, x + options.inputSlice.left, y + options.inputSlice.top};
}
Iterator &operator++() {
@@ -496,13 +259,14 @@ public:
return ++it; // ...now one-past-last!
}
};
public:
TilesVisitor visitAsTiles() const {
return {
*this,
options.columnMajor,
options.inputSlice.width ? options.inputSlice.width * 8 : width,
options.inputSlice.height ? options.inputSlice.height * 8 : height,
options.inputSlice.width ? options.inputSlice.width * 8 : png.width,
options.inputSlice.height ? options.inputSlice.height * 8 : png.height,
};
}
};
@@ -522,10 +286,7 @@ private:
public:
// Creates a new raw tile, and returns a reference to it so it can be filled in
RawTile &newTile() {
_tiles.emplace_back();
return _tiles.back();
}
RawTile &newTile() { return _tiles.emplace_back(); }
};
struct AttrmapEntry {
@@ -547,32 +308,30 @@ struct AttrmapEntry {
}
};
static void generatePalSpec(Png const &png) {
static void generatePalSpec(Image const &image) {
// Generate a palette spec from the first few colors in the embedded palette
auto [embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha] = png.getEmbeddedPal();
if (embPalRGB == nullptr) {
std::vector<Rgba> const &embPal = image.png.palette;
if (embPal.empty()) {
fatal("`-c embedded` was given, but the PNG does not have an embedded palette!");
}
// Ignore extraneous colors if they are unused
size_t nbColors = embPal.size();
if (nbColors > options.maxOpaqueColors()) {
nbColors = options.maxOpaqueColors();
}
// Fill in the palette spec
options.palSpec.clear();
options.palSpec.emplace_back(); // A single palette, with `#00000000`s (transparent)
assume(options.palSpec.size() == 1);
if (embPalSize > options.maxOpaqueColors()) { // Ignore extraneous colors if they are unused
embPalSize = options.maxOpaqueColors();
}
for (int i = 0; i < embPalSize; ++i) {
options.palSpec[0][i] = Rgba(
embPalRGB[i].red,
embPalRGB[i].green,
embPalRGB[i].blue,
embPalAlpha && i < embPalAlphaSize ? embPalAlpha[i] : 0xFF
);
auto &palette = options.palSpec.emplace_back();
assume(nbColors <= palette.size());
for (size_t i = 0; i < nbColors; ++i) {
palette[i] = embPal[i];
}
}
static std::tuple<std::vector<size_t>, std::vector<Palette>>
generatePalettes(std::vector<ProtoPalette> const &protoPalettes, Png const &png) {
generatePalettes(std::vector<ProtoPalette> const &protoPalettes, Image const &image) {
// Run a "pagination" problem solver
auto [mappings, nbPalettes] = overloadAndRemove(protoPalettes);
assume(mappings.size() == protoPalettes.size());
@@ -609,16 +368,15 @@ static std::tuple<std::vector<size_t>, std::vector<Palette>>
// "Sort" colors in the generated palettes, see the man page for the flowchart
if (options.palSpecType == Options::DMG) {
sortGrayscale(palettes, png.getColors().raw());
} else if (auto [embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha] = png.getEmbeddedPal();
embPalRGB != nullptr) {
sortGrayscale(palettes, image.colors.raw());
} else if (!image.png.palette.empty()) {
warning(
WARNING_EMBEDDED,
"Sorting palette colors by PNG's embedded PLTE chunk without '-c/--colors embedded'"
);
sortIndexed(palettes, embPalSize, embPalRGB, embPalAlphaSize, embPalAlpha);
} else if (png.isSuitableForGrayscale()) {
sortGrayscale(palettes, png.getColors().raw());
sortIndexed(palettes, image.png.palette);
} else if (image.isSuitableForGrayscale()) {
sortGrayscale(palettes, image.colors.raw());
} else {
sortRgb(palettes);
}
@@ -641,8 +399,8 @@ static std::tuple<std::vector<size_t>, std::vector<Palette>>
auto listColors = [](auto const &list) {
static char buf[sizeof(", $XXXX, $XXXX, $XXXX, $XXXX")];
char *ptr = buf;
for (uint16_t cgbColor : list) {
ptr += snprintf(ptr, sizeof(", $XXXX"), ", $%04x", cgbColor);
for (uint16_t color : list) {
ptr += snprintf(ptr, sizeof(", $XXXX"), ", $%04x", color);
}
return &buf[literal_strlen(", ")];
};
@@ -753,7 +511,7 @@ public:
mutable uint16_t tileID;
static uint16_t
rowBitplanes(Png::TilesVisitor::Tile const &tile, Palette const &palette, uint32_t y) {
rowBitplanes(Image::TilesVisitor::Tile const &tile, Palette const &palette, uint32_t y) {
uint16_t row = 0;
for (uint32_t x = 0; x < 8; ++x) {
row <<= 1;
@@ -776,7 +534,7 @@ public:
}
}
TileData(Png::TilesVisitor::Tile const &tile, Palette const &palette) : _hash(0) {
TileData(Image::TilesVisitor::Tile const &tile, Palette const &palette) : _hash(0) {
size_t writeIndex = 0;
for (uint32_t y = 0; y < 8; ++y) {
uint16_t bitplanes = rowBitplanes(tile, palette, y);
@@ -856,7 +614,7 @@ struct std::hash<TileData> {
};
static void outputUnoptimizedTileData(
Png const &png,
Image const &image,
std::vector<AttrmapEntry> const &attrmap,
std::vector<Palette> const &palettes,
std::vector<size_t> const &mappings
@@ -868,14 +626,14 @@ static void outputUnoptimizedTileData(
// LCOV_EXCL_STOP
}
uint16_t widthTiles = options.inputSlice.width ? options.inputSlice.width : png.getWidth() / 8;
uint16_t widthTiles = options.inputSlice.width ? options.inputSlice.width : image.png.width / 8;
uint16_t heightTiles =
options.inputSlice.height ? options.inputSlice.height : png.getHeight() / 8;
options.inputSlice.height ? options.inputSlice.height : image.png.height / 8;
uint64_t nbTiles = widthTiles * heightTiles;
uint64_t nbKeptTiles = nbTiles > options.trim ? nbTiles - options.trim : 0;
uint64_t tileIdx = 0;
for (auto [tile, attr] : zip(png.visitAsTiles(), attrmap)) {
for (auto [tile, attr] : zip(image.visitAsTiles(), attrmap)) {
// Do not emit fully-background tiles.
if (attr.isBackgroundTile()) {
++tileIdx;
@@ -993,7 +751,7 @@ struct UniqueTiles {
// 8-bit tile IDs + the bank bit; this will save the work when we output the data later (potentially
// twice)
static UniqueTiles dedupTiles(
Png const &png,
Image const &image,
std::vector<AttrmapEntry> &attrmap,
std::vector<Palette> const &palettes,
std::vector<size_t> const &mappings
@@ -1044,7 +802,7 @@ static UniqueTiles dedupTiles(
}
bool inputWithoutOutput = !options.inputTileset.empty() && options.output.empty();
for (auto [tile, attr] : zip(png.visitAsTiles(), attrmap)) {
for (auto [tile, attr] : zip(image.visitAsTiles(), attrmap)) {
if (attr.isBackgroundTile()) {
attr.xFlip = false;
attr.yFlip = false;
@@ -1157,16 +915,12 @@ void process() {
options.verbosePrint(Options::VERB_CFG, "Using libpng %s\n", png_get_libpng_ver(nullptr));
options.verbosePrint(Options::VERB_LOG_ACT, "Reading tiles...\n");
Png png(options.input); // This also sets `hasTransparentPixels` as a side effect
ImagePalette const &colors = png.getColors();
// Now, we have all the image's colors in `colors`
// The next step is to order the palette
Image image(options.input); // This also sets `hasTransparentPixels` as a side effect
// LCOV_EXCL_START
if (options.verbosity >= Options::VERB_INTERM) {
fputs("Image colors: [ ", stderr);
for (std::optional<Rgba> const &slot : colors) {
for (std::optional<Rgba> const &slot : image.colors) {
if (!slot.has_value()) {
continue;
}
@@ -1182,7 +936,7 @@ void process() {
"Image contains transparent pixels, not compatible with a DMG palette specification"
);
}
if (!png.isSuitableForGrayscale()) {
if (!image.isSuitableForGrayscale()) {
fatal("Image contains too many or non-gray colors, not compatible with a DMG palette "
"specification");
}
@@ -1195,7 +949,7 @@ void process() {
std::vector<ProtoPalette> protoPalettes;
std::vector<AttrmapEntry> attrmap{};
for (auto tile : png.visitAsTiles()) {
for (auto tile : image.visitAsTiles()) {
AttrmapEntry &attrs = attrmap.emplace_back();
// Count the unique non-transparent colors for packing
@@ -1227,8 +981,8 @@ void process() {
}
ProtoPalette protoPalette;
for (uint16_t cgbColor : tileColors) {
protoPalette.add(cgbColor);
for (uint16_t color : tileColors) {
protoPalette.add(color);
}
if (options.bgColor.has_value()
@@ -1295,17 +1049,17 @@ continue_visiting_tiles:;
// LCOV_EXCL_STOP
if (options.palSpecType == Options::EMBEDDED) {
generatePalSpec(png);
generatePalSpec(image);
}
auto [mappings, palettes] =
options.palSpecType == Options::NO_SPEC || options.palSpecType == Options::DMG
? generatePalettes(protoPalettes, png)
? generatePalettes(protoPalettes, image)
: makePalsAsSpecified(protoPalettes);
outputPalettes(palettes);
// If deduplication is not happening, we just need to output the tile data and/or maps as-is
if (!options.allowDedup) {
uint32_t const nbTilesH = png.getHeight() / 8, nbTilesW = png.getWidth() / 8;
uint32_t const nbTilesH = image.png.height / 8, nbTilesW = image.png.width / 8;
// Check the tile count
if (uint32_t nbTiles = nbTilesW * nbTilesH;
@@ -1326,7 +1080,7 @@ continue_visiting_tiles:;
if (!options.output.empty()) {
options.verbosePrint(Options::VERB_LOG_ACT, "Generating unoptimized tile data...\n");
outputUnoptimizedTileData(png, attrmap, palettes, mappings);
outputUnoptimizedTileData(image, attrmap, palettes, mappings);
}
if (!options.tilemap.empty() || !options.attrmap.empty() || !options.palmap.empty()) {
@@ -1339,7 +1093,7 @@ continue_visiting_tiles:;
} else {
// All of these require the deduplication process to be performed to be output
options.verbosePrint(Options::VERB_LOG_ACT, "Deduplicating tiles...\n");
UniqueTiles tiles = dedupTiles(png, attrmap, palettes, mappings);
UniqueTiles tiles = dedupTiles(image, attrmap, palettes, mappings);
if (size_t nbTiles = tiles.size();
nbTiles > options.maxNbTiles[0] + options.maxNbTiles[1]) {

View File

@@ -1,2 +1,2 @@
FATAL: Error reading input image ("damaged1.png"): IDAT: invalid code -- missing end-of-block
FATAL: Error reading PNG image ("damaged1.png"): IDAT: invalid code -- missing end-of-block
Conversion aborted after 1 error

View File

@@ -1,2 +1,2 @@
FATAL: Error reading input image ("damaged2.png"): IDAT: invalid code -- missing end-of-block
FATAL: Error reading PNG image ("damaged2.png"): IDAT: invalid code -- missing end-of-block
Conversion aborted after 1 error

View File

@@ -1,2 +1,2 @@
FATAL: Error reading input image ("damaged9.png"): IDAT: invalid code -- missing end-of-block
FATAL: Error reading PNG image ("damaged9.png"): IDAT: invalid code -- missing end-of-block
Conversion aborted after 1 error

View File

@@ -0,0 +1 @@
-m

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

BIN
test/gfx/interlaced.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B