diff --git a/common/Image.cpp b/common/Image.cpp index 24f247aec3..1376af2350 100644 --- a/common/Image.cpp +++ b/common/Image.cpp @@ -32,6 +32,11 @@ static bool WebPBufferSaver(const RGBA8Image& image, std::vector* buffer, u8 static bool WebPFileLoader(RGBA8Image* image, const char* filename, std::FILE* fp); static bool WebPFileSaver(const RGBA8Image& image, const char* filename, std::FILE* fp, u8 quality); +static bool BMPBufferLoader(RGBA8Image* image, const void* buffer, size_t buffer_size); +static bool BMPBufferSaver(const RGBA8Image& image, std::vector* buffer, u8 quality); +static bool BMPFileLoader(RGBA8Image* image, const char* filename, std::FILE* fp); +static bool BMPFileSaver(const RGBA8Image& image, const char* filename, std::FILE* fp, u8 quality); + struct FormatHandler { const char* extension; @@ -46,6 +51,7 @@ static constexpr FormatHandler s_format_handlers[] = { {"jpg", JPEGBufferLoader, JPEGBufferSaver, JPEGFileLoader, JPEGFileSaver}, {"jpeg", JPEGBufferLoader, JPEGBufferSaver, JPEGFileLoader, JPEGFileSaver}, {"webp", WebPBufferLoader, WebPBufferSaver, WebPFileLoader, WebPFileSaver}, + {"bmp", BMPBufferLoader, BMPBufferSaver, BMPFileLoader, BMPFileSaver}, }; static const FormatHandler* GetFormatHandler(const std::string_view extension) @@ -734,3 +740,617 @@ bool WebPFileSaver(const RGBA8Image& image, const char* filename, std::FILE* fp, return (std::fwrite(buffer.data(), buffer.size(), 1, fp) == 1); } + +// Some of this code is adapted from Qt's BMP handler (https://github.com/qt/qtbase/blob/dev/src/gui/image/qbmphandler.cpp) +#pragma pack(push, 1) +struct BMPFileHeader +{ + u16 type; + u32 size; + u16 reserved1; + u16 reserved2; + u32 offset; +}; + +struct BMPInfoHeader +{ + u32 size; + s32 width; + s32 height; + u16 planes; + u16 bit_count; + u32 compression; + u32 size_image; + s32 x_pels_per_meter; + s32 y_pels_per_meter; + u32 clr_used; + u32 clr_important; +}; +#pragma pack(pop) + +bool IsSupportedBMPFormat(u32 compression, u16 bit_count) +{ + if (compression == 0) + return (bit_count == 1 || bit_count == 4 || bit_count == 8 || bit_count == 16 || bit_count == 24 || bit_count == 32); + + if (compression == 1) + return (bit_count == 8); + + if (compression == 2) + return (bit_count == 4); + + if (compression == 3 || compression == 4) // BMP_BITFIELDS or BMP_ALPHABITFIELDS + return (bit_count == 16 || bit_count == 32); + + return false; +} + +bool LoadBMPPalette(std::vector& palette, const u8* data, u32 palette_offset, const BMPInfoHeader& info_header) +{ + // 1 bit format doesn't use a palette in the traditional sense + if (info_header.bit_count == 1) + { + palette = {0xFFFFFFFF, 0xFF000000}; + return true; + } + + const u32 num_colors = (info_header.clr_used > 0) ? info_header.clr_used : (1u << info_header.bit_count); + + // Make sure that we don't have an unreasonably large palette + if (num_colors > 256) + { + Console.Error("Invalid palette size: %u", num_colors); + return false; + } + + palette.clear(); + palette.reserve(num_colors); + + const u8* palette_data = data + sizeof(BMPFileHeader) + info_header.size; + + for (u32 i = 0; i < num_colors; i++) + { + const u8* color = palette_data + (i * 4); + const u8 b = color[0]; + const u8 g = color[1]; + const u8 r = color[2]; + palette.push_back(r | (g << 8) | (b << 16) | 0xFF000000u); + } + + return true; +} + +bool LoadUncompressedBMP(u32* pixels, const u8* src, const u8* data, u32 width, u32 height, const BMPInfoHeader& info_header, const std::vector& palette, bool flip_vertical, u32 red_mask = 0, u32 green_mask = 0, u32 blue_mask = 0, u32 alpha_mask = 0, bool use_alpha = false) +{ + const u32 row_size = ((width * info_header.bit_count + 31) / 32) * 4; + + for (u32 y = 0; y < height; y++) + { + u32 dst_y = flip_vertical ? (height - 1 - y) : y; + const u8* row_src = src + (y * row_size); + u32* row_dst = pixels + (dst_y * width); + + u32 bit_offset = 0; + + for (u32 x = 0; x < width; x++) + { + u32 pixel_value = 0; + + switch (info_header.bit_count) + { + case 1: + { + const u32 byte_index = bit_offset / 8; + const u32 bit_index = 7 - (bit_offset % 8); + pixel_value = (row_src[byte_index] >> bit_index) & 1; + bit_offset += 1; + break; + } + case 4: + { + const u32 byte_index = bit_offset / 8; + const u32 nibble_index = (bit_offset % 8) / 4; + pixel_value = (row_src[byte_index] >> (nibble_index * 4)) & 0xF; + bit_offset += 4; + break; + } + case 8: + { + pixel_value = row_src[bit_offset / 8]; + bit_offset += 8; + break; + } + case 16: + { + const u32 byte_index = bit_offset / 8; + pixel_value = row_src[byte_index] | (row_src[byte_index + 1] << 8); + bit_offset += 16; + + if (info_header.compression == 3) + { + const u8* bitfields = data + sizeof(BMPFileHeader) + info_header.size; + const u32 r_mask = *reinterpret_cast(bitfields); + const u32 g_mask = *reinterpret_cast(bitfields + 4); + const u32 b_mask = *reinterpret_cast(bitfields + 8); + + u32 r_shift = 0, g_shift = 0, b_shift = 0; + u32 temp = r_mask; + while (temp >>= 1) + r_shift++; + temp = g_mask; + while (temp >>= 1) + g_shift++; + temp = b_mask; + while (temp >>= 1) + b_shift++; + + const u8 r = static_cast((pixel_value & r_mask) >> r_shift); + const u8 g = static_cast((pixel_value & g_mask) >> g_shift); + const u8 b = static_cast((pixel_value & b_mask) >> b_shift); + + const u8 r_max = static_cast(r_mask >> r_shift); + const u8 g_max = static_cast(g_mask >> g_shift); + const u8 b_max = static_cast(b_mask >> b_shift); + + const u8 r_scaled = (r_max > 0) ? static_cast((r * 255) / r_max) : 0; + const u8 g_scaled = (g_max > 0) ? static_cast((g * 255) / g_max) : 0; + const u8 b_scaled = (b_max > 0) ? static_cast((b * 255) / b_max) : 0; + + row_dst[x] = r_scaled | (g_scaled << 8) | (b_scaled << 16) | 0xFF000000u; + } + else + { + const u8 r = (pixel_value >> 10) & 0x1F; + const u8 g = (pixel_value >> 5) & 0x1F; + const u8 b = pixel_value & 0x1F; + row_dst[x] = (r << 3) | (g << 11) | (b << 19) | 0xFF000000u; + } + continue; + } + case 24: + { + const u32 byte_index = bit_offset / 8; + const u8 b = row_src[byte_index + 0]; + const u8 g = row_src[byte_index + 1]; + const u8 r = row_src[byte_index + 2]; + row_dst[x] = r | (g << 8) | (b << 16) | 0xFF000000u; + bit_offset += 24; + continue; + } + case 32: + { + const u32 byte_index = bit_offset / 8; + u32 pixel_value = row_src[byte_index] | (row_src[byte_index + 1] << 8) | (row_src[byte_index + 2] << 16) | (row_src[byte_index + 3] << 24); + bit_offset += 32; + + if (info_header.compression == 3 || info_header.compression == 4) // BITFIELDS or ALPHABITFIELDS + { + // Calculate shifts + auto calc_shift = [](u32 mask) -> u32 { + u32 result = 0; + while ((mask >= 0x100) || (!(mask & 1) && mask)) + { + result++; + mask >>= 1; + } + return result; + }; + + // Calculate scales + auto calc_scale = [](u32 low_mask) -> u32 { + u32 result = 8; + while (low_mask && result) + { + result--; + low_mask >>= 1; + } + return result; + }; + + // Apply scale + auto apply_scale = [](u32 value, u32 scale) -> u8 { + if (!(scale & 0x07)) // scale == 8 or 0 + return static_cast(value); + u32 filled = 8 - scale; + u32 result = value << scale; + do + { + result |= result >> filled; + filled <<= 1; + } while (filled < 8); + return static_cast(result); + }; + + const u32 r_shift = calc_shift(red_mask); + const u32 g_shift = calc_shift(green_mask); + const u32 b_shift = calc_shift(blue_mask); + const u32 a_shift = (alpha_mask != 0) ? calc_shift(alpha_mask) : 0; + + const u32 r_scale = calc_scale(red_mask >> r_shift); + const u32 g_scale = calc_scale(green_mask >> g_shift); + const u32 b_scale = calc_scale(blue_mask >> b_shift); + const u32 a_scale = (alpha_mask != 0) ? calc_scale(alpha_mask >> a_shift) : 0; + + const u8 r = apply_scale((pixel_value & red_mask) >> r_shift, r_scale); + const u8 g = apply_scale((pixel_value & green_mask) >> g_shift, g_scale); + const u8 b = apply_scale((pixel_value & blue_mask) >> b_shift, b_scale); + const u8 a = (use_alpha && alpha_mask != 0) ? apply_scale((pixel_value & alpha_mask) >> a_shift, a_scale) : 0xFF; + + row_dst[x] = r | (g << 8) | (b << 16) | (a << 24); + } + else + { + // Uncompressed 32-bit BGRA order + const u8 b = row_src[byte_index + 0]; + const u8 g = row_src[byte_index + 1]; + const u8 r = row_src[byte_index + 2]; + const u8 a = row_src[byte_index + 3]; + row_dst[x] = r | (g << 8) | (b << 16) | (a << 24); + } + continue; + } + } + if (info_header.bit_count <= 8) + { + if (pixel_value < palette.size()) + row_dst[x] = palette[pixel_value]; + else + { + Console.Error("Invalid palette index: %u (palette size: %zu)", pixel_value, palette.size()); + return false; + } + } + } + } + return true; +} + +bool LoadCompressedBMP(u32* pixels, const u8* src, u32 src_size, u32 width, u32 height, const BMPInfoHeader& info_header, const std::vector& palette, bool flip_vertical) +{ + u32 src_pos = 0; + const u32 pixel_size = (info_header.bit_count == 8) ? 1 : 2; + + for (u32 y = 0; y < height; y++) + { + u32 dst_y = flip_vertical ? (height - 1 - y) : y; + u32* row_dst = pixels + (dst_y * width); + u32 x = 0; + + while (x < width) + { + // Check bounds before reading + if (src_pos + 2 > src_size) + return false; + + const u8 count = src[src_pos++]; + const u8 value = src[src_pos++]; + + if (count == 0) + { + if (value == 0) + { + break; + } + else if (value == 1) + { + return true; + } + else if (value == 2) + { + // Delta (jump) need 2 more bytes + if (src_pos + 2 > src_size) + return false; + const u8 dx = src[src_pos++]; + const u8 dy = src[src_pos++]; + x += dx; + y += dy; + if (y >= height || x >= width) + return false; + const u32 new_dst_y = flip_vertical ? (height - 1 - y) : y; + row_dst = pixels + (new_dst_y * width); + } + else + { + // Absolute mode need "value" bytes of pixel data + const u32 run_length = value; + const u32 bytes_needed = run_length * pixel_size; + if (src_pos + bytes_needed > src_size) + return false; + + for (u32 i = 0; i < run_length; i++) + { + if (x >= width) + break; + + u8 pixel_value = 0; + if (info_header.bit_count == 8) + { + pixel_value = src[src_pos++]; + } + else + { + const u8 byte_val = src[src_pos++]; + pixel_value = (i % 2 == 0) ? (byte_val >> 4) : (byte_val & 0x0F); + } + + row_dst[x++] = (pixel_value < palette.size()) ? palette[pixel_value] : 0; + } + + if ((run_length * pixel_size) % 2 == 1) + src_pos++; + } + } + else + { + u8 pixel_value = value; + + for (u32 i = 0; i < count; i++) + { + if (x >= width) + break; + row_dst[x++] = (pixel_value < palette.size()) ? palette[pixel_value] : 0; + } + } + } + } + + return true; +} + +bool BMPBufferLoader(RGBA8Image* image, const void* buffer, size_t buffer_size) +{ + if (buffer_size < sizeof(BMPFileHeader) + sizeof(BMPInfoHeader)) + { + Console.Error("BMP file too small"); + return false; + } + + const u8* data = static_cast(buffer); + BMPFileHeader file_header; + BMPInfoHeader info_header; + + std::memcpy(&file_header, data, sizeof(BMPFileHeader)); + std::memcpy(&info_header, data + sizeof(BMPFileHeader), sizeof(BMPInfoHeader)); + + if (file_header.type != 0x4D42) + { + Console.Error("Invalid BMP signature"); + return false; + } + + // Check for extended header versions (V4=108 bytes, V5=124 bytes) + // We read as BITMAPINFOHEADER (40 bytes) regardless, since extended headers just add fields at the end + if (info_header.size == 108) + { + Console.Warning("BITMAPV4HEADER detected, reading as BITMAPINFOHEADER"); + } + else if (info_header.size == 124) + { + Console.Warning("BITMAPV5HEADER detected, reading as BITMAPINFOHEADER"); + } + else if (info_header.size != 40) + { + Console.Warning("Unknown BMP header size: %u, attempting to read as BITMAPINFOHEADER", info_header.size); + } + + if (!IsSupportedBMPFormat(info_header.compression, info_header.bit_count)) + { + Console.Error("Unsupported BMP format: compression=%u, bit_count=%u", info_header.compression, info_header.bit_count); + return false; + } + + const u32 width = static_cast(std::abs(info_header.width)); + const u32 height = static_cast(std::abs(info_header.height)); + const bool flip_vertical = (info_header.height > 0); + + if (width == 0 || height == 0) + { + Console.Error("Invalid BMP dimensions: %ux%u", width, height); + return false; + } + + if (width > 65536 || height > 65536) + { + Console.Error("BMP dimensions too large: %ux%u", width, height); + return false; + } + + Console.WriteLn("BMP: %ux%u, %u-bit, compression=%u", width, height, info_header.bit_count, info_header.compression); + + // Read color masks from header or bitfields + u32 red_mask = 0; + u32 green_mask = 0; + u32 blue_mask = 0; + u32 alpha_mask = 0; + const bool bitfields = (info_header.compression == 3 || info_header.compression == 4); // BMP_BITFIELDS or BMP_ALPHABITFIELDS + const u8* header_start = data + sizeof(BMPFileHeader); + const u32 header_base_offset = sizeof(BMPFileHeader) + 40; // Base header is 40 bytes + + if (info_header.size >= 108) // BMP_WIN4 (108) or BMP_WIN5 (124) + { + // V4/V5 headers masks come right after the 40-byte base header + // Masks are at offsets from header_start: red=40, green=44, blue=48, alpha=52 + if (buffer_size >= header_base_offset + 16) // Need space for 4 masks + { + red_mask = *reinterpret_cast(header_start + 40); + green_mask = *reinterpret_cast(header_start + 44); + blue_mask = *reinterpret_cast(header_start + 48); + alpha_mask = *reinterpret_cast(header_start + 52); + } + } + else if (bitfields && (info_header.bit_count == 16 || info_header.bit_count == 32)) + { + const u32 bitfields_offset = sizeof(BMPFileHeader) + info_header.size; + if (buffer_size >= bitfields_offset + 12) // Need space for at least r/g/b masks + { + red_mask = *reinterpret_cast(data + bitfields_offset); + green_mask = *reinterpret_cast(data + bitfields_offset + 4); + blue_mask = *reinterpret_cast(data + bitfields_offset + 8); + if (info_header.compression == 4) // BMP_ALPHABITFIELDS + { + // Read alpha mask: r, g, b, a + if (buffer_size >= bitfields_offset + 16) + alpha_mask = *reinterpret_cast(data + bitfields_offset + 12); + } + // For BMP_BITFIELDS (3), alpha_mask stays 0 + } + } + + bool use_alpha = bitfields || (info_header.compression == 0 && info_header.bit_count == 32 && alpha_mask == 0xff000000); + use_alpha = use_alpha && (alpha_mask != 0); + + const u32 bytes_per_pixel = info_header.bit_count / 8; + const u32 row_size = ((width * bytes_per_pixel + 3) / 4) * 4; + + // For uncompressed BMPs, verify we have enough data + // For RLE-compressed BMPs, size is variable so we check differently + if (info_header.compression == 0) + { + if (file_header.offset + (row_size * height) > buffer_size) + { + Console.Error("BMP file data incomplete"); + return false; + } + } + else + { + // For RLE-compressed BMPs, check that we have at least the offset and some data + // Use biSizeImage if available, otherwise just verify offset is valid + if (file_header.offset >= buffer_size) + { + Console.Error("BMP file data incomplete"); + return false; + } + if (info_header.size_image > 0) + { + if (file_header.offset + info_header.size_image > buffer_size) + { + Console.Error("BMP file data incomplete"); + return false; + } + } + } + + std::vector pixels; + pixels.resize(width * height); + + const u8* src = data + file_header.offset; + const u32 src_size = buffer_size - file_header.offset; + + std::vector palette; + if (info_header.bit_count <= 8) + { + if (!LoadBMPPalette(palette, data, file_header.offset, info_header)) + { + Console.Error("Failed to load BMP palette"); + return false; + } + } + + if (info_header.compression == 0 || info_header.compression == 3 || info_header.compression == 4) + { + if (!LoadUncompressedBMP(pixels.data(), src, data, width, height, info_header, palette, flip_vertical, red_mask, green_mask, blue_mask, alpha_mask, use_alpha)) + { + Console.Error("Failed to load uncompressed BMP data"); + return false; + } + } + else + { + if (!LoadCompressedBMP(pixels.data(), src, src_size, width, height, info_header, palette, flip_vertical)) + { + Console.Error("Failed to load compressed BMP data"); + return false; + } + } + + // Handle alpha channel for 32-bit BMPs + // Only use alpha if alpha_mask is explicitly set in header/bitfields + if (info_header.bit_count == 32 && !use_alpha) + { + // Alpha mask not set or zero - set all pixels to fully opaque + for (u32& pixel : pixels) + pixel |= 0xFF000000u; + } + + image->SetPixels(width, height, std::move(pixels)); + return true; +} + +bool BMPFileLoader(RGBA8Image* image, const char* filename, std::FILE* fp) +{ + std::optional> data = FileSystem::ReadBinaryFile(fp); + if (!data.has_value()) + return false; + + return BMPBufferLoader(image, data->data(), data->size()); +} + +bool BMPBufferSaver(const RGBA8Image& image, std::vector* buffer, u8 quality) +{ + const u32 width = image.GetWidth(); + const u32 height = image.GetHeight(); + + // Check dimensions + if (width == 0 || height == 0) + { + Console.Error("Invalid BMP dimensions: %ux%u", width, height); + return false; + } + + const u32 row_size = ((width * 3 + 3) / 4) * 4; + const u32 image_size = row_size * height; + const u32 file_size = sizeof(BMPFileHeader) + sizeof(BMPInfoHeader) + image_size; + + buffer->resize(file_size); + u8* data = buffer->data(); + + BMPFileHeader file_header = {}; + file_header.type = 0x4D42; + file_header.size = file_size; + file_header.reserved1 = 0; + file_header.reserved2 = 0; + file_header.offset = sizeof(BMPFileHeader) + sizeof(BMPInfoHeader); + std::memcpy(data, &file_header, sizeof(BMPFileHeader)); + + BMPInfoHeader info_header = {}; + info_header.size = sizeof(BMPInfoHeader); + info_header.width = static_cast(width); + info_header.height = static_cast(height); + info_header.planes = 1; + info_header.bit_count = 24; + info_header.compression = 0; + info_header.size_image = image_size; + info_header.x_pels_per_meter = 0; + info_header.y_pels_per_meter = 0; + info_header.clr_used = 0; + info_header.clr_important = 0; + std::memcpy(data + sizeof(BMPFileHeader), &info_header, sizeof(BMPInfoHeader)); + + u8* pixel_data = data + file_header.offset; + for (u32 y = 0; y < height; y++) + { + const u32 src_y = height - 1 - y; + const u32* row_src = image.GetRowPixels(src_y); + u8* row_dst = pixel_data + (y * row_size); + + for (u32 x = 0; x < width; x++) + { + const u32 rgba = row_src[x]; + row_dst[x * 3 + 0] = static_cast((rgba >> 16) & 0xFF); + row_dst[x * 3 + 1] = static_cast((rgba >> 8) & 0xFF); + row_dst[x * 3 + 2] = static_cast(rgba & 0xFF); + } + } + + return true; +} + +bool BMPFileSaver(const RGBA8Image& image, const char* filename, std::FILE* fp, u8 quality) +{ + std::vector buffer; + if (!BMPBufferSaver(image, &buffer, quality)) + return false; + + return (std::fwrite(buffer.data(), buffer.size(), 1, fp) == 1); +}