/* * Copyright (c) 2023, Nico Weber * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include // Overview: https://developers.google.com/speed/webp/docs/compression // Container: https://developers.google.com/speed/webp/docs/riff_container // Lossless format: https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification // Lossy format: https://datatracker.ietf.org/doc/html/rfc6386 namespace Gfx { namespace { struct FourCC { constexpr FourCC(char const* name) { cc[0] = name[0]; cc[1] = name[1]; cc[2] = name[2]; cc[3] = name[3]; } bool operator==(FourCC const&) const = default; bool operator!=(FourCC const&) const = default; char cc[4]; }; // https://developers.google.com/speed/webp/docs/riff_container#webp_file_header struct WebPFileHeader { FourCC riff; LittleEndian file_size; FourCC webp; }; static_assert(AssertSize()); struct ChunkHeader { FourCC chunk_type; LittleEndian chunk_size; }; static_assert(AssertSize()); struct Chunk { FourCC type; ReadonlyBytes data; }; struct VP8Header { u8 version; bool show_frame; u32 size_of_first_partition; u32 width; u8 horizontal_scale; u32 height; u8 vertical_scale; }; struct VP8LHeader { u16 width; u16 height; bool is_alpha_used; }; struct VP8XHeader { bool has_icc; bool has_alpha; bool has_exif; bool has_xmp; bool has_animation; u32 width; u32 height; }; } struct WebPLoadingContext { enum State { NotDecoded = 0, Error, HeaderDecoded, FirstChunkDecoded, SizeDecoded, ChunksDecoded, BitmapDecoded, }; State state { State::NotDecoded }; ReadonlyBytes data; ReadonlyBytes chunks_cursor; Optional size; RefPtr bitmap; // Either 'VP8 ' (simple lossy file), 'VP8L' (simple lossless file), or 'VP8X' (extended file). Optional first_chunk; // If first_chunk is not a VP8X chunk, then only image_data_chunk is set and all the other Chunks are not set. // "For a still image, the image data consists of a single frame, which is made up of: // An optional alpha subchunk. // A bitstream subchunk." Optional alpha_chunk; // 'ALPH' Optional image_data_chunk; // Either 'VP8 ' or 'VP8L'. Optional animation_header_chunk; // 'ANIM' Vector animation_frame_chunks; // 'ANMF' Optional iccp_chunk; // 'ICCP' Optional exif_chunk; // 'EXIF' Optional xmp_chunk; // 'XMP ' template [[nodiscard]] class Error error(char const (&string_literal)[N]) { state = WebPLoadingContext::State::Error; return Error::from_string_literal(string_literal); } }; // https://developers.google.com/speed/webp/docs/riff_container#webp_file_header static ErrorOr decode_webp_header(WebPLoadingContext& context) { if (context.state >= WebPLoadingContext::HeaderDecoded) return {}; if (context.data.size() < sizeof(WebPFileHeader)) return context.error("Missing WebP header"); auto& header = *bit_cast(context.data.data()); if (header.riff != FourCC("RIFF") || header.webp != FourCC("WEBP")) return context.error("Invalid WebP header"); // "File Size: [...] The size of the file in bytes starting at offset 8. The maximum value of this field is 2^32 minus 10 bytes." u32 const maximum_webp_file_size = 0xffff'ffff - 9; if (header.file_size > maximum_webp_file_size) return context.error("WebP header file size over maximum"); // "The file size in the header is the total size of the chunks that follow plus 4 bytes for the 'WEBP' FourCC. // The file SHOULD NOT contain any data after the data specified by File Size. // Readers MAY parse such files, ignoring the trailing data." if (context.data.size() - 8 < header.file_size) return context.error("WebP data too small for size in header"); if (context.data.size() - 8 > header.file_size) { dbgln_if(WEBP_DEBUG, "WebP has {} bytes of data, but header needs only {}. Trimming.", context.data.size(), header.file_size + 8); context.data = context.data.trim(header.file_size + 8); } context.state = WebPLoadingContext::HeaderDecoded; return {}; } // https://developers.google.com/speed/webp/docs/riff_container#riff_file_format static ErrorOr decode_webp_chunk_header(WebPLoadingContext& context, ReadonlyBytes chunks) { if (chunks.size() < sizeof(ChunkHeader)) return context.error("Not enough data for WebP chunk header"); auto const& header = *bit_cast(chunks.data()); dbgln_if(WEBP_DEBUG, "chunk {} size {}", header.chunk_type, header.chunk_size); if (chunks.size() < sizeof(ChunkHeader) + header.chunk_size) return context.error("Not enough data for WebP chunk"); return Chunk { header.chunk_type, { chunks.data() + sizeof(ChunkHeader), header.chunk_size } }; } // https://developers.google.com/speed/webp/docs/riff_container#riff_file_format static ErrorOr decode_webp_advance_chunk(WebPLoadingContext& context, ReadonlyBytes& chunks) { auto chunk = TRY(decode_webp_chunk_header(context, chunks)); // "Chunk Size: 32 bits (uint32) // The size of the chunk in bytes, not including this field, the chunk identifier or padding. // Chunk Payload: Chunk Size bytes // The data payload. If Chunk Size is odd, a single padding byte -- that MUST be 0 to conform with RIFF -- is added." chunks = chunks.slice(sizeof(ChunkHeader) + chunk.data.size()); if (chunk.data.size() % 2 != 0) { if (chunks.is_empty()) return context.error("Missing data for padding byte"); if (*chunks.data() != 0) return context.error("Padding byte is not 0"); chunks = chunks.slice(1); } return chunk; } // https://developers.google.com/speed/webp/docs/riff_container#simple_file_format_lossy // https://datatracker.ietf.org/doc/html/rfc6386#section-19 "Annex A: Bitstream Syntax" static ErrorOr decode_webp_chunk_VP8_header(WebPLoadingContext& context, Chunk const& vp8_chunk) { VERIFY(vp8_chunk.type == FourCC("VP8 ")); if (vp8_chunk.data.size() < 10) return context.error("WebPImageDecoderPlugin: 'VP8 ' chunk too small"); // FIXME: Eventually, this should probably call into LibVideo/VP8, // and image decoders should move into LibImageDecoders which depends on both LibGfx and LibVideo. // (LibVideo depends on LibGfx, so LibGfx can't depend on LibVideo itself.) // https://datatracker.ietf.org/doc/html/rfc6386#section-4 "Overview of Compressed Data Format" // "The decoder is simply presented with a sequence of compressed frames [...] // The first frame presented to the decompressor is [...] a key frame. [...] // [E]very compressed frame has three or more pieces. It begins with an uncompressed data chunk comprising 10 bytes in the case of key frames u8 const* data = vp8_chunk.data.data(); // https://datatracker.ietf.org/doc/html/rfc6386#section-9.1 "Uncompressed Data Chunk" u32 frame_tag = data[0] | (data[1] << 8) | (data[2] << 16); bool is_key_frame = (frame_tag & 1) == 0; // https://www.rfc-editor.org/errata/eid5534 u8 version = (frame_tag & 0xe) >> 1; bool show_frame = (frame_tag & 0x10) != 0; u32 size_of_first_partition = frame_tag >> 5; if (!is_key_frame) return context.error("WebPImageDecoderPlugin: 'VP8 ' chunk not a key frame"); // FIXME: !show_frame does not make sense in a webp file either, probably? u32 start_code = data[3] | (data[4] << 8) | (data[5] << 16); if (start_code != 0x2a019d) // https://www.rfc-editor.org/errata/eid7370 return context.error("WebPImageDecoderPlugin: 'VP8 ' chunk invalid start_code"); // "The scaling specifications for each dimension are encoded as follows. // 0 | No upscaling (the most common case). // 1 | Upscale by 5/4. // 2 | Upscale by 5/3. // 3 | Upscale by 2." // This is a display-time operation and doesn't affect decoding. u16 width_and_horizontal_scale = data[6] | (data[7] << 8); u16 width = width_and_horizontal_scale & 0x3fff; u8 horizontal_scale = width_and_horizontal_scale >> 14; u16 heigth_and_vertical_scale = data[8] | (data[9] << 8); u16 height = heigth_and_vertical_scale & 0x3fff; u8 vertical_scale = heigth_and_vertical_scale >> 14; dbgln_if(WEBP_DEBUG, "version {}, show_frame {}, size_of_first_partition {}, width {}, horizontal_scale {}, height {}, vertical_scale {}", version, show_frame, size_of_first_partition, width, horizontal_scale, height, vertical_scale); return VP8Header { version, show_frame, size_of_first_partition, width, horizontal_scale, height, vertical_scale }; } // https://developers.google.com/speed/webp/docs/riff_container#simple_file_format_lossless // https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification#7_overall_structure_of_the_format static ErrorOr decode_webp_chunk_VP8L_header(WebPLoadingContext& context, Chunk const& vp8l_chunk) { VERIFY(vp8l_chunk.type == FourCC("VP8L")); // https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification#3_riff_header if (vp8l_chunk.data.size() < 5) return context.error("WebPImageDecoderPlugin: VP8L chunk too small"); u8 const* data = vp8l_chunk.data.data(); u8 signature = data[0]; if (signature != 0x2f) return context.error("WebPImageDecoderPlugin: VP8L chunk invalid signature"); // 14 bits width-1, 14 bits height-1, 1 bit alpha hint, 3 bit version_number. u16 width = (data[1] | ((data[2] & 0x3f) << 8)) + 1; u16 height = ((data[2] >> 6) | (data[3] << 2) | ((data[4] & 0xf) << 12)) + 1; bool is_alpha_used = (data[4] & 0x10) != 0; u8 version_number = (data[4] & 0xe0) >> 5; dbgln_if(WEBP_DEBUG, "width {}, height {}, is_alpha_used {}, version_number {}", width, height, is_alpha_used, version_number); // "The version_number is a 3 bit code that must be set to 0. Any other value should be treated as an error. [AMENDED]" if (version_number != 0) return context.error("WebPImageDecoderPlugin: VP8L chunk invalid version_number"); return VP8LHeader { width, height, is_alpha_used }; } static ErrorOr decode_webp_chunk_VP8X(WebPLoadingContext& context, Chunk const& vp8x_chunk) { VERIFY(vp8x_chunk.type == FourCC("VP8X")); // The VP8X chunk is documented at "Extended WebP file header:" at the end of // https://developers.google.com/speed/webp/docs/riff_container#extended_file_format if (vp8x_chunk.data.size() < 10) return context.error("WebPImageDecoderPlugin: VP8X chunk too small"); u8 const* data = vp8x_chunk.data.data(); // 1 byte flags // "Reserved (Rsv): 2 bits MUST be 0. Readers MUST ignore this field. // ICC profile (I): 1 bit Set if the file contains an ICC profile. // Alpha (L): 1 bit Set if any of the frames of the image contain transparency information ("alpha"). // Exif metadata (E): 1 bit Set if the file contains Exif metadata. // XMP metadata (X): 1 bit Set if the file contains XMP metadata. // Animation (A): 1 bit Set if this is an animated image. Data in 'ANIM' and 'ANMF' chunks should be used to control the animation. // Reserved (R): 1 bit MUST be 0. Readers MUST ignore this field." u8 flags = data[0]; bool has_icc = flags & 0x20; bool has_alpha = flags & 0x10; bool has_exif = flags & 0x8; bool has_xmp = flags & 0x4; bool has_animation = flags & 0x2; // 3 bytes reserved // 3 bytes width minus one u32 width = (data[4] | (data[5] << 8) | (data[6] << 16)) + 1; // 3 bytes height minus one u32 height = (data[7] | (data[8] << 8) | (data[9] << 16)) + 1; dbgln_if(WEBP_DEBUG, "flags 0x{:x} --{}{}{}{}{}{}, width {}, height {}", flags, has_icc ? " icc" : "", has_alpha ? " alpha" : "", has_exif ? " exif" : "", has_xmp ? " xmp" : "", has_animation ? " anim" : "", (flags & 0x3e) == 0 ? " none" : "", width, height); return VP8XHeader { has_icc, has_alpha, has_exif, has_xmp, has_animation, width, height }; } // https://developers.google.com/speed/webp/docs/riff_container#extended_file_format static ErrorOr decode_webp_extended(WebPLoadingContext& context, ReadonlyBytes chunks) { // FIXME: This isn't quite to spec, which says // "All chunks SHOULD be placed in the same order as listed above. // If a chunk appears in the wrong place, the file is invalid, but readers MAY parse the file, ignoring the chunks that are out of order." auto store = [](auto& field, Chunk const& chunk) { if (!field.has_value()) field = chunk; }; while (!chunks.is_empty()) { auto chunk = TRY(decode_webp_advance_chunk(context, chunks)); if (chunk.type == FourCC("ICCP")) store(context.iccp_chunk, chunk); else if (chunk.type == FourCC("ALPH")) store(context.alpha_chunk, chunk); else if (chunk.type == FourCC("ANIM")) store(context.animation_header_chunk, chunk); else if (chunk.type == FourCC("ANMF")) TRY(context.animation_frame_chunks.try_append(chunk)); else if (chunk.type == FourCC("EXIF")) store(context.exif_chunk, chunk); else if (chunk.type == FourCC("XMP ")) store(context.xmp_chunk, chunk); else if (chunk.type == FourCC("VP8 ") || chunk.type == FourCC("VP8L")) store(context.image_data_chunk, chunk); } context.state = WebPLoadingContext::State::ChunksDecoded; return {}; } static ErrorOr decode_webp_first_chunk(WebPLoadingContext& context) { if (context.state >= WebPLoadingContext::State::FirstChunkDecoded) return {}; if (context.state < WebPLoadingContext::HeaderDecoded) TRY(decode_webp_header(context)); context.chunks_cursor = context.data.slice(sizeof(WebPFileHeader)); auto first_chunk = TRY(decode_webp_advance_chunk(context, context.chunks_cursor)); if (first_chunk.type != FourCC("VP8 ") && first_chunk.type != FourCC("VP8L") && first_chunk.type != FourCC("VP8X")) return context.error("WebPImageDecoderPlugin: Invalid first chunk type"); context.first_chunk = first_chunk; context.state = WebPLoadingContext::State::FirstChunkDecoded; if (first_chunk.type == FourCC("VP8 ") || first_chunk.type == FourCC("VP8L")) context.image_data_chunk = first_chunk; return {}; } static ErrorOr decode_webp_size(WebPLoadingContext& context) { if (context.state >= WebPLoadingContext::State::SizeDecoded) return {}; if (context.state < WebPLoadingContext::FirstChunkDecoded) TRY(decode_webp_first_chunk(context)); if (context.first_chunk->type == FourCC("VP8 ")) { auto header = TRY(decode_webp_chunk_VP8_header(context, context.first_chunk.value())); context.size = IntSize { header.width, header.height }; context.state = WebPLoadingContext::State::SizeDecoded; return {}; } if (context.first_chunk->type == FourCC("VP8L")) { auto header = TRY(decode_webp_chunk_VP8L_header(context, context.first_chunk.value())); context.size = IntSize { header.width, header.height }; context.state = WebPLoadingContext::State::SizeDecoded; return {}; } VERIFY(context.first_chunk->type == FourCC("VP8X")); auto header = TRY(decode_webp_chunk_VP8X(context, context.first_chunk.value())); context.size = IntSize { header.width, header.height }; context.state = WebPLoadingContext::State::SizeDecoded; return {}; } static ErrorOr decode_webp_chunks(WebPLoadingContext& context) { if (context.state >= WebPLoadingContext::State::ChunksDecoded) return {}; if (context.state < WebPLoadingContext::SizeDecoded) TRY(decode_webp_size(context)); if (context.first_chunk->type == FourCC("VP8X")) return decode_webp_extended(context, context.chunks_cursor); context.state = WebPLoadingContext::State::ChunksDecoded; return {}; } WebPImageDecoderPlugin::WebPImageDecoderPlugin(ReadonlyBytes data, OwnPtr context) : m_context(move(context)) { m_context->data = data; } WebPImageDecoderPlugin::~WebPImageDecoderPlugin() = default; IntSize WebPImageDecoderPlugin::size() { if (m_context->state == WebPLoadingContext::State::Error) return {}; if (m_context->state < WebPLoadingContext::State::SizeDecoded) { if (decode_webp_size(*m_context).is_error()) return {}; } return m_context->size.value(); } void WebPImageDecoderPlugin::set_volatile() { if (m_context->bitmap) m_context->bitmap->set_volatile(); } bool WebPImageDecoderPlugin::set_nonvolatile(bool& was_purged) { if (!m_context->bitmap) return false; return m_context->bitmap->set_nonvolatile(was_purged); } bool WebPImageDecoderPlugin::initialize() { return !decode_webp_header(*m_context).is_error(); } ErrorOr WebPImageDecoderPlugin::sniff(ReadonlyBytes data) { WebPLoadingContext context; context.data = data; TRY(decode_webp_header(context)); return true; } ErrorOr> WebPImageDecoderPlugin::create(ReadonlyBytes data) { auto context = TRY(try_make()); return adopt_nonnull_own_or_enomem(new (nothrow) WebPImageDecoderPlugin(data, move(context))); } bool WebPImageDecoderPlugin::is_animated() { // FIXME return false; } size_t WebPImageDecoderPlugin::loop_count() { // FIXME return 0; } size_t WebPImageDecoderPlugin::frame_count() { // FIXME return 1; } ErrorOr WebPImageDecoderPlugin::frame(size_t index) { if (index >= frame_count()) return Error::from_string_literal("WebPImageDecoderPlugin: Invalid frame index"); return Error::from_string_literal("WebPImageDecoderPlugin: decoding not yet implemented"); } ErrorOr> WebPImageDecoderPlugin::icc_data() { TRY(decode_webp_chunks(*m_context)); // FIXME: "If this chunk is not present, sRGB SHOULD be assumed." return m_context->iccp_chunk.map([](auto iccp_chunk) { return iccp_chunk.data; }); } } template<> struct AK::Formatter : StandardFormatter { ErrorOr format(FormatBuilder& builder, Gfx::FourCC const& four_cc) { TRY(builder.put_padding('\'', 1)); TRY(builder.put_padding(four_cc.cc[0], 1)); TRY(builder.put_padding(four_cc.cc[1], 1)); TRY(builder.put_padding(four_cc.cc[2], 1)); TRY(builder.put_padding(four_cc.cc[3], 1)); TRY(builder.put_padding('\'', 1)); return {}; } };