diff --git a/include/gfx/pal_spec.hpp b/include/gfx/pal_spec.hpp index 88f8446d..201eb080 100644 --- a/include/gfx/pal_spec.hpp +++ b/include/gfx/pal_spec.hpp @@ -9,8 +9,7 @@ #ifndef RGBDS_GFX_PAL_SPEC_HPP #define RGBDS_GFX_PAL_SPEC_HPP -#include - void parseInlinePalSpec(char const * const arg); +void parseExternalPalSpec(char const *arg); #endif /* RGBDS_GFX_PAL_SPEC_HPP */ diff --git a/src/gfx/main.cpp b/src/gfx/main.cpp index 49156298..1498a8db 100644 --- a/src/gfx/main.cpp +++ b/src/gfx/main.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include "extern/getopt.h" #include "platform.h" @@ -33,6 +34,7 @@ using namespace std::literals::string_view_literals; Options options; +char const *externalPalSpec = nullptr; static uintmax_t nbErrors; void warning(char const *fmt, ...) { @@ -222,21 +224,6 @@ static void skipWhitespace(char *&arg) { arg += strcspn(arg, " \t"); } -static void parsePaletteSpec(char const *arg) { - if (arg[0] == '#') { - options.palSpecType = Options::EXPLICIT; - parseInlinePalSpec(arg); - } else if (strcasecmp(arg, "embedded") == 0) { - // Use PLTE, error out if missing - options.palSpecType = Options::EMBEDDED; - } else { - // `fmt:path`, parse the file according to the given format - // TODO: split both parts, error out if malformed or file not found - options.palSpecType = Options::EXPLICIT; - // TODO - } -} - static void registerInput(char const *arg) { if (!options.input.empty()) { fprintf(stderr, @@ -375,7 +362,20 @@ static char *parseArgv(int argc, char **argv, bool &autoAttrmap, bool &autoTilem options.useColorCurve = true; break; case 'c': - parsePaletteSpec(musl_optarg); + if (musl_optarg[0] == '#') { + options.palSpecType = Options::EXPLICIT; + parseInlinePalSpec(musl_optarg); + } else if (strcasecmp(musl_optarg, "embedded") == 0) { + // Use PLTE, error out if missing + options.palSpecType = Options::EMBEDDED; + } else { + options.palSpecType = Options::EXPLICIT; + // Can't parse the file yet, as "flat" color collections need to know the palette + // size to be split; thus, we defer that + // TODO: this does not validate the `fmt` part of any external spec but the last + // one, but I guess that's okay + externalPalSpec = musl_optarg; + } break; case 'D': warning("Ignoring retired option `-D`"); @@ -611,6 +611,11 @@ int main(int argc, char *argv[]) { autoOutPath(autoTilemap, options.tilemap, ".tilemap"); autoOutPath(autoPalettes, options.palettes, ".pal"); + // Execute deferred external pal spec parsing, now that all other params are known + if (externalPalSpec) { + parseExternalPalSpec(externalPalSpec); + } + if (options.verbosity >= Options::VERB_CFG) { fprintf(stderr, "rgbgfx %s\n", get_package_version_string()); @@ -706,7 +711,8 @@ int main(int argc, char *argv[]) { // Do not do anything if option parsing went wrong if (nbErrors) { - fprintf(stderr, "Conversion aborted after %ju error%s\n", nbErrors, nbErrors == 1 ? "" : "s"); + fprintf(stderr, "Conversion aborted after %ju error%s\n", nbErrors, + nbErrors == 1 ? "" : "s"); return 1; } @@ -717,7 +723,8 @@ int main(int argc, char *argv[]) { } if (nbErrors) { - fprintf(stderr, "Conversion aborted after %ju error%s\n", nbErrors, nbErrors == 1 ? "" : "s"); + fprintf(stderr, "Conversion aborted after %ju error%s\n", nbErrors, + nbErrors == 1 ? "" : "s"); return 1; } return 0; diff --git a/src/gfx/pal_spec.cpp b/src/gfx/pal_spec.cpp index 6bf95488..fe69ab07 100644 --- a/src/gfx/pal_spec.cpp +++ b/src/gfx/pal_spec.cpp @@ -10,7 +10,21 @@ #include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform.h" #include "gfx/main.hpp" @@ -37,6 +51,11 @@ constexpr uint8_t singleToHex(char c) { return toHex(c, c); } +template // Should be std::string or std::string_view +static void skipWhitespace(Str const &str, typename Str::size_type &pos) { + pos = std::min(str.find_first_not_of(" \t", pos), str.length()); +} + void parseInlinePalSpec(char const * const rawArg) { // List of #rrggbb/#rgb colors, comma-separated, palettes are separated by colons @@ -63,13 +82,8 @@ void parseInlinePalSpec(char const * const rawArg) { putc('\n', stderr); }; - auto skipWhitespace = [&arg](size_type &pos) { - pos = std::min(arg.find_first_not_of(" \t", pos), arg.length()); - }; - options.palSpec.clear(); - options.palSpec - .emplace_back(); // Not default-initialized, but value-initialized, so we get zeros + options.palSpec.emplace_back(); // Value-initialized, not default-init'd, so we get zeros size_type n = 0; // Index into the argument // TODO: store max `nbColors` ever reached, and compare against palette size later @@ -98,7 +112,7 @@ void parseInlinePalSpec(char const * const rawArg) { n = pos; // Skip whitespace, if any - skipWhitespace(n); + skipWhitespace(arg, n); // Skip comma/colon, or end if (n == arg.length()) { @@ -111,7 +125,7 @@ void parseInlinePalSpec(char const * const rawArg) { ++nbColors; // A trailing comma may be followed by a colon - skipWhitespace(n); + skipWhitespace(arg, n); if (n == arg.length()) { break; } else if (arg[n] != ':') { @@ -125,7 +139,7 @@ void parseInlinePalSpec(char const * const rawArg) { case ':': ++n; - skipWhitespace(n); + skipWhitespace(arg, n); nbColors = 0; // Start a new palette // Avoid creating a spurious empty palette @@ -149,3 +163,289 @@ void parseInlinePalSpec(char const * const rawArg) { } } } + +/** + * Tries to read some magic bytes from the provided `file`. + * Returns whether the magic was correctly read. + */ +template +static bool readMagic(std::filebuf &file, char const *magic) { + assert(strlen(magic) == n); + + char magicBuf[n]; + return file.sgetn(magicBuf, n) == n && memcmp(magicBuf, magic, n); +} + +// Like `readMagic`, but automatically determines the size from the string literal's length. +// Don't worry if you make a mistake, an `assert`'s got your back! +#define READ_MAGIC(file, magic) \ + readMagic(file, magic) // Don't count the terminator + +template +static T readBE(U const *bytes) { + T val = 0; + for (size_t i = 0; i < sizeof(val); ++i) { + val = val << 8 | static_cast(bytes[i]); + } + return val; +} + +/** + * **Appends** the first line read from `file` to the end of the provided `buffer`. + */ +static void readLine(std::filebuf &file, std::string &buffer) { + // TODO: maybe this can be optimized to bulk reads? + for (;;) { + auto c = file.sbumpc(); + if (c == std::filebuf::traits_type::eof()) { + return; + } + if (c == '\n') { + // Discard a trailing CRLF + if (!buffer.empty() && buffer.back() == '\r') { + buffer.pop_back(); + } + return; + } + + buffer.push_back(c); + } +} + +// FIXME: Normally we'd use `std::from_chars`, but that's not available with GCC 7 +/** + * Parses the initial part of a string_view, advancing the "read index" as it does + */ +static uint16_t parseDec(std::string const &str, std::string::size_type &n) { + uint32_t value = 0; // Use a larger type to handle overflow more easily + for (auto end = std::min(str.length(), str.find_first_not_of("0123456789", n)); n < end; ++n) { + value = std::min(value * 10 + (str[n] - '0'), UINT16_MAX); + } + + return value; +} + +static void parsePSPFile(std::filebuf &file) { + // https://www.selapa.net/swatches/colors/fileformats.php#psp_pal + + std::string line; + readLine(file, line); + if (line != "JASC-PAL") { + error("Palette file does not appear to be a PSP palette file"); + return; + } + + line.clear(); + readLine(file, line); + if (line != "0100") { + error("Unsupported PSP palette file version \"%s\"", line.c_str()); + return; + } + + line.clear(); + readLine(file, line); + std::string::size_type n = 0; + uint16_t nbColors = parseDec(line, n); + if (n != line.length()) { + error("Invalid \"number of colors\" line in PSP file (%s)", line.c_str()); + return; + } + + if (nbColors > options.nbColorsPerPal * options.nbPalettes) { + warning("PSP file contains %" PRIu16 " colors, but there can only be %" PRIu16 + "; ignoring extra", + nbColors, options.nbColorsPerPal * options.nbPalettes); + nbColors = options.nbColorsPerPal * options.nbPalettes; + } + + options.palSpec.clear(); + + for (uint16_t i = 0; i < nbColors; ++i) { + line.clear(); + readLine(file, line); + n = 0; + + // TODO: parse R G B + uint8_t r = parseDec(line, n); + skipWhitespace(line, n); + if (n == line.length()) { + error("Failed to parse color #%" PRIu16 " (\"%s\"): missing green component", i + 1, + line.c_str()); + return; + } + uint8_t g = parseDec(line, n); + if (n == line.length()) { + error("Failed to parse color #%" PRIu16 " (\"%s\"): missing green component", i + 1, + line.c_str()); + return; + } + skipWhitespace(line, n); + uint8_t b = parseDec(line, n); + if (n != line.length()) { + error("Failed to parse color #%" PRIu16 + " (\"%s\"): trailing characters after blue component", + i + 1, line.c_str()); + return; + } + + if (i % options.nbColorsPerPal == 0) { + options.palSpec.emplace_back(); + } + options.palSpec.back()[i % options.nbColorsPerPal] = Rgba(r, g, b, 0xFF); + } +} + +void parseACTFile(std::filebuf &file) { + // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577411_pgfId-1070626 + + std::array buf; + auto len = file.sgetn(buf.data(), buf.size()); + + uint16_t nbColors = 256; + if (len == 772) { + nbColors = readBE(&buf[768]); + // TODO: apparently there is a "transparent color index"? What? + if (nbColors > 256 || nbColors == 0) { + error("Invalid number of colors in ACT file (%" PRIu16 ")", nbColors); + return; + } + } else if (len != 768) { + error("Invalid file size for ACT file (expected 768 or 772 bytes, got %zu", len); + return; + } + + if (nbColors > options.nbColorsPerPal * options.nbPalettes) { + warning("ACT file contains %" PRIu16 " colors, but there can only be %" PRIu16 + "; ignoring extra", + nbColors, options.nbColorsPerPal * options.nbPalettes); + nbColors = options.nbColorsPerPal * options.nbPalettes; + } + + options.palSpec.clear(); + options.palSpec.emplace_back(); + + char const *ptr = buf.data(); + size_t colorIdx = 0; + for (uint16_t i = 0; i < nbColors; ++i) { + Rgba &color = options.palSpec.back()[colorIdx]; + color = Rgba(ptr[0], ptr[1], ptr[2], 0xFF); + + ptr += 3; + ++colorIdx; + if (colorIdx == options.nbColorsPerPal) { + options.palSpec.emplace_back(); + colorIdx = 0; + } + } + + // Remove the spurious empty palette if there is one + if (colorIdx == 0) { + options.palSpec.pop_back(); + } +} + +void parseACOFile(std::filebuf &file) { + // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577411_pgfId-1055819 + // http://www.nomodes.com/aco.html + + char buf[10]; + + if (file.sgetn(buf, 2) != 2) { + error("Couldn't read ACO file version"); + return; + } + if (readBE(buf) != 1) { + error("Palette file does not appear to be an ACO file"); + return; + } + + if (file.sgetn(buf, 2) != 2) { + error("Couldn't read number of colors in palette file"); + return; + } + uint16_t nbColors = readBE(buf); + + if (nbColors > options.nbColorsPerPal * options.nbPalettes) { + warning("ACO file contains %" PRIu16 " colors, but there can only be %" PRIu16 + "; ignoring extra", + nbColors, options.nbColorsPerPal * options.nbPalettes); + nbColors = options.nbColorsPerPal * options.nbPalettes; + } + + options.palSpec.clear(); + + for (uint16_t i = 0; i < nbColors; ++i) { + if (file.sgetn(buf, 10) != 10) { + error("Failed to read color #%" PRIu16 " from palette file", i + 1); + return; + } + + if (i % options.nbColorsPerPal == 0) { + options.palSpec.emplace_back(); + } + + Rgba &color = options.palSpec.back()[i % options.nbColorsPerPal]; + uint16_t colorType = readBE(buf); + switch (colorType) { + case 0: // RGB + color = Rgba(buf[0], buf[2], buf[4], 0xFF); + break; + case 1: // HSB + error("Unsupported color type (HSB) for ACO file"); + return; + case 2: // CMYK + error("Unsupported color type (CMYK) for ACO file"); + return; + case 7: // Lab + error("Unsupported color type (lab) for ACO file"); + return; + case 8: // Grayscale + error("Unsupported color type (grayscale) for ACO file"); + return; + default: + error("Unknown color type (%" PRIu16 ") for ACO file", colorType); + return; + } + } + + // TODO: maybe scan the v2 data instead (if present) + // `codecvt` can be used to convert from UTF-16 to UTF-8 +} + +void parseExternalPalSpec(char const *arg) { + // `fmt:path`, parse the file according to the given format + + // Split both parts, error out if malformed + char const *ptr = strchr(arg, ':'); + if (ptr == nullptr) { + error("External palette spec must have format `fmt:path` (missing colon)"); + return; + } + char const *path = ptr + 1; + + static std::array parsers{ + std::tuple{"PSP", &parsePSPFile, std::ios::in }, + std::tuple{"ACT", &parseACTFile, std::ios::binary}, + std::tuple{"ACO", &parseACOFile, std::ios::binary}, + }; + + auto iter = std::find_if(parsers.begin(), parsers.end(), + [&arg, &ptr](decltype(parsers)::value_type const &parser) { + return strncasecmp(arg, std::get<0>(parser), ptr - arg) == 0; + }); + if (iter == parsers.end()) { + error("Unknown external palette format \"%.*s\"", + static_cast(std::min(ptr - arg, static_cast(INT_MAX))), + arg); + return; + } + + std::filebuf file; + // Some parsers read the file in text mode, others in binary mode + if (!file.open(path, std::ios::in | std::get<2>(*iter))) { + error("Failed to open palette file \"%s\"", path); + return; + } + + std::get<1> (*iter)(file); +}