Newer
Older
minerva / Userland / Libraries / LibGfx / ImageFormats / PNGWriter.cpp
@minerva minerva on 13 Jul 19 KB Initial commit
/*
 * Copyright (c) 2021, Pierre Hoffmeister
 * Copyright (c) 2021, Andreas Kling <kling@serenityos.org>
 * Copyright (c) 2021, Aziz Berkay Yesilyurt <abyesilyurt@gmail.com>
 * Copyright (c) 2024, Torben Jonas Virtmann
 *
 * SPDX-License-Identifier: BSD-2-Clause
 */

#include <AK/Concepts.h>
#include <AK/FixedArray.h>
#include <AK/MemoryStream.h>
#include <AK/SIMDExtras.h>
#include <AK/String.h>
#include <LibCompress/Zlib.h>
#include <LibCrypto/Checksum/CRC32.h>
#include <LibGfx/Bitmap.h>
#include <LibGfx/ImageFormats/PNGWriter.h>

namespace Gfx {

class PNGChunk {
    using data_length_type = u32;

public:
    explicit PNGChunk(String);
    auto const& data() const { return m_data; }
    String const& type() const { return m_type; }
    ErrorOr<void> reserve(size_t bytes) { return m_data.try_ensure_capacity(bytes); }

    template<typename T>
    ErrorOr<void> add_as_big_endian(T);

    ErrorOr<void> add_u8(u8);

    ErrorOr<void> compress_and_add(ReadonlyBytes, Compress::ZlibCompressionLevel);
    ErrorOr<void> add(ReadonlyBytes);

    ErrorOr<void> store_type();
    void store_data_length();
    u32 crc();

private:
    ByteBuffer m_data;
    String m_type;
};

PNGChunk::PNGChunk(String type)
    : m_type(move(type))
{
    VERIFY(m_type.bytes().size() == 4);

    // NOTE: These are MUST() because they should always be able to fit in m_data's inline capacity.
    MUST(add_as_big_endian<data_length_type>(0));
    MUST(store_type());
}

ErrorOr<void> PNGChunk::store_type()
{
    TRY(add(type().bytes()));
    return {};
}

void PNGChunk::store_data_length()
{
    auto data_length = BigEndian<u32>(m_data.size() - sizeof(data_length_type) - m_type.bytes().size());
    __builtin_memcpy(m_data.offset_pointer(0), &data_length, sizeof(u32));
}

u32 PNGChunk::crc()
{
    u32 crc = Crypto::Checksum::CRC32({ m_data.offset_pointer(sizeof(data_length_type)), m_data.size() - sizeof(data_length_type) }).digest();
    return crc;
}

ErrorOr<void> PNGChunk::compress_and_add(ReadonlyBytes uncompressed_bytes, Compress::ZlibCompressionLevel compression_level)
{
    return add(TRY(Compress::ZlibCompressor::compress_all(uncompressed_bytes, compression_level)));
}

ErrorOr<void> PNGChunk::add(ReadonlyBytes bytes)
{
    TRY(m_data.try_append(bytes));
    return {};
}

template<typename T>
ErrorOr<void> PNGChunk::add_as_big_endian(T data)
{
    auto data_out = AK::convert_between_host_and_big_endian(data);
    TRY(m_data.try_append(&data_out, sizeof(T)));
    return {};
}

ErrorOr<void> PNGChunk::add_u8(u8 data)
{
    TRY(m_data.try_append(data));
    return {};
}

PNGWriter::PNGWriter(Stream& stream)
    : m_stream(stream)
{
}

ErrorOr<void> PNGWriter::add_chunk(PNGChunk& png_chunk)
{
    png_chunk.store_data_length();
    u32 crc = png_chunk.crc();
    TRY(png_chunk.add_as_big_endian(crc));
    TRY(m_stream.write_until_depleted(png_chunk.data()));
    return {};
}

ErrorOr<void> PNGWriter::add_png_header()
{
    TRY(m_stream.write_until_depleted(PNG::header));
    return {};
}

ErrorOr<void> PNGWriter::add_acTL_chunk(u32 num_frames, u32 loop_count)
{
    // https://www.w3.org/TR/png/#acTL-chunk
    PNGChunk png_chunk { "acTL"_string };
    TRY(png_chunk.add_as_big_endian(num_frames));
    TRY(png_chunk.add_as_big_endian(loop_count));
    TRY(add_chunk(png_chunk));
    return {};
}

struct fcTLData {
    u32 sequence_number { 0 };
    u32 width { 0 };
    u32 height { 0 };
    u32 x_offset { 0 };
    u32 y_offset { 0 };
    u16 delay_numerator { 0 };
    u16 delay_denominator { 1 };
    // dispose_op values
    // 0           APNG_DISPOSE_OP_NONE
    // 1           APNG_DISPOSE_OP_BACKGROUND
    // 2           APNG_DISPOSE_OP_PREVIOUS
    u8 dispose_operation { 0 };
    // blend_op values
    // value
    // 0       APNG_BLEND_OP_SOURCE
    // 1       APNG_BLEND_OP_OVER
    u8 blend_operation { 0 };
};

ErrorOr<void> PNGWriter::add_fcTL_chunk(fcTLData const& data)
{
    // https://www.w3.org/TR/png/#fcTL-chunk

    // TODO: Constraints on frame regions
    PNGChunk png_chunk { "fcTL"_string };
    TRY(png_chunk.add_as_big_endian(data.sequence_number));
    TRY(png_chunk.add_as_big_endian(data.width));
    TRY(png_chunk.add_as_big_endian(data.height));
    TRY(png_chunk.add_as_big_endian(data.x_offset));
    TRY(png_chunk.add_as_big_endian(data.y_offset));
    TRY(png_chunk.add_as_big_endian(data.delay_numerator));
    TRY(png_chunk.add_as_big_endian(data.delay_denominator));
    TRY(png_chunk.add_u8(data.dispose_operation));
    TRY(png_chunk.add_u8(data.blend_operation));
    TRY(add_chunk(png_chunk));
    return {};
}

ErrorOr<void> PNGWriter::add_IHDR_chunk(u32 width, u32 height, u8 bit_depth, PNG::ColorType color_type, u8 compression_method, u8 filter_method, u8 interlace_method)
{
    PNGChunk png_chunk { "IHDR"_string };
    TRY(png_chunk.add_as_big_endian(width));
    TRY(png_chunk.add_as_big_endian(height));
    TRY(png_chunk.add_u8(bit_depth));
    TRY(png_chunk.add_u8(to_underlying(color_type)));
    TRY(png_chunk.add_u8(compression_method));
    TRY(png_chunk.add_u8(filter_method));
    TRY(png_chunk.add_u8(interlace_method));
    TRY(add_chunk(png_chunk));
    return {};
}

ErrorOr<void> PNGWriter::add_iCCP_chunk(ReadonlyBytes icc_data, Compress::ZlibCompressionLevel compression_level)
{
    // https://www.w3.org/TR/png/#11iCCP
    PNGChunk chunk { "iCCP"_string };

    TRY(chunk.add("embedded profile"sv.bytes()));
    TRY(chunk.add_u8(0)); // \0-terminate profile name

    TRY(chunk.add_u8(0)); // compression method deflate
    TRY(chunk.compress_and_add(icc_data, compression_level));

    TRY(add_chunk(chunk));
    return {};
}

ErrorOr<void> PNGWriter::add_IEND_chunk()
{
    PNGChunk png_chunk { "IEND"_string };
    TRY(add_chunk(png_chunk));
    return {};
}

union [[gnu::packed]] Pixel {
    ARGB32 rgba { 0 };
    AK::SIMD::u8x4 simd;

    ALWAYS_INLINE static AK::SIMD::u8x4 argb32_to_simd(Pixel pixel)
    {
        return pixel.simd;
    }
};
static_assert(AssertSize<Pixel, 4>());

template<bool include_alpha, bool include_colors>
static ErrorOr<void> add_image_data_to_chunk_impl(Gfx::Bitmap const& bitmap, PNGChunk& png_chunk, Compress::ZlibCompressionLevel compression_level)
{
    ByteBuffer uncompressed_block_data;
    TRY(uncompressed_block_data.try_ensure_capacity(bitmap.size_in_bytes() + bitmap.height()));

    auto dummy_scanline = TRY(FixedArray<Pixel>::create(bitmap.width()));
    auto const* scanline_minus_1 = dummy_scanline.data();

    for (int y = 0; y < bitmap.height(); ++y) {
        auto* scanline = reinterpret_cast<Pixel const*>(bitmap.scanline(y));

        struct Filter {
            PNG::FilterType type;
            AK::SIMD::u32x4 sum { 0, 0, 0, 0 };

            AK::SIMD::u8x4 predict(AK::SIMD::u8x4 pixel, AK::SIMD::u8x4 pixel_x_minus_1, AK::SIMD::u8x4 pixel_y_minus_1, AK::SIMD::u8x4 pixel_xy_minus_1)
            {
                switch (type) {
                case PNG::FilterType::None:
                    return pixel;
                case PNG::FilterType::Sub:
                    return pixel - pixel_x_minus_1;
                case PNG::FilterType::Up:
                    return pixel - pixel_y_minus_1;
                case PNG::FilterType::Average: {
                    // The sum Orig(a) + Orig(b) shall be performed without overflow (using at least nine-bit arithmetic).
                    auto sum = AK::SIMD::simd_cast<AK::SIMD::u16x4>(pixel_x_minus_1) + AK::SIMD::simd_cast<AK::SIMD::u16x4>(pixel_y_minus_1);
                    auto average = AK::SIMD::simd_cast<AK::SIMD::u8x4>(sum / 2);
                    return pixel - average;
                }
                case PNG::FilterType::Paeth:
                    return pixel - PNG::paeth_predictor(pixel_x_minus_1, pixel_y_minus_1, pixel_xy_minus_1);
                }
                VERIFY_NOT_REACHED();
            }

            void append(AK::SIMD::u8x4 simd)
            {
                using namespace AK::SIMD;
                sum += simd_cast<u32x4>(abs(simd_cast<i32x4>(simd_cast<i8x4>(simd))));
            }

            u32 sum_of_abs_values() const
            {
                u32 result = sum[0];
                if constexpr (include_colors)
                    result += sum[1] + sum[2];
                if constexpr (include_alpha)
                    result += sum[3];
                return result;
            }
        };

        Filter none_filter { .type = PNG::FilterType::None };
        Filter sub_filter { .type = PNG::FilterType::Sub };
        Filter up_filter { .type = PNG::FilterType::Up };
        Filter average_filter { .type = PNG::FilterType::Average };
        Filter paeth_filter { .type = PNG::FilterType::Paeth };

        auto pixel_x_minus_1 = Pixel::argb32_to_simd(dummy_scanline[0]);
        auto pixel_xy_minus_1 = Pixel::argb32_to_simd(dummy_scanline[0]);

        for (int x = 0; x < bitmap.width(); ++x) {
            auto pixel = Pixel::argb32_to_simd(scanline[x]);
            auto pixel_y_minus_1 = Pixel::argb32_to_simd(scanline_minus_1[x]);

            none_filter.append(none_filter.predict(pixel, pixel_x_minus_1, pixel_y_minus_1, pixel_xy_minus_1));
            sub_filter.append(sub_filter.predict(pixel, pixel_x_minus_1, pixel_y_minus_1, pixel_xy_minus_1));
            up_filter.append(up_filter.predict(pixel, pixel_x_minus_1, pixel_y_minus_1, pixel_xy_minus_1));
            average_filter.append(average_filter.predict(pixel, pixel_x_minus_1, pixel_y_minus_1, pixel_xy_minus_1));
            paeth_filter.append(paeth_filter.predict(pixel, pixel_x_minus_1, pixel_y_minus_1, pixel_xy_minus_1));

            pixel_x_minus_1 = pixel;
            pixel_xy_minus_1 = pixel_y_minus_1;
        }

        // 12.8 Filter selection: https://www.w3.org/TR/PNG/#12Filter-selection
        // For best compression of truecolour and greyscale images, the recommended approach
        // is adaptive filtering in which a filter is chosen for each scanline.
        // The following simple heuristic has performed well in early tests:
        // compute the output scanline using all five filters, and select the filter that gives the smallest sum of absolute values of outputs.
        // (Consider the output bytes as signed differences for this test.)
        Filter& best_filter = none_filter;
        if (best_filter.sum_of_abs_values() > sub_filter.sum_of_abs_values())
            best_filter = sub_filter;
        if (best_filter.sum_of_abs_values() > up_filter.sum_of_abs_values())
            best_filter = up_filter;
        if (best_filter.sum_of_abs_values() > average_filter.sum_of_abs_values())
            best_filter = average_filter;
        if (best_filter.sum_of_abs_values() > paeth_filter.sum_of_abs_values())
            best_filter = paeth_filter;

        TRY(uncompressed_block_data.try_append(to_underlying(best_filter.type)));

        pixel_x_minus_1 = Pixel::argb32_to_simd(dummy_scanline[0]);
        pixel_xy_minus_1 = Pixel::argb32_to_simd(dummy_scanline[0]);

        for (int x = 0; x < bitmap.width(); ++x) {
            auto pixel = Pixel::argb32_to_simd(scanline[x]);
            auto pixel_y_minus_1 = Pixel::argb32_to_simd(scanline_minus_1[x]);

            auto predicted_pixel = best_filter.predict(pixel, pixel_x_minus_1, pixel_y_minus_1, pixel_xy_minus_1);
            if constexpr (include_colors) {
                TRY(uncompressed_block_data.try_append(predicted_pixel[2]));
                TRY(uncompressed_block_data.try_append(predicted_pixel[1]));
            }
            TRY(uncompressed_block_data.try_append(predicted_pixel[0]));
            if constexpr (include_alpha)
                TRY(uncompressed_block_data.try_append(predicted_pixel[3]));

            pixel_x_minus_1 = pixel;
            pixel_xy_minus_1 = pixel_y_minus_1;
        }

        scanline_minus_1 = scanline;
    }

    return png_chunk.compress_and_add(uncompressed_block_data, compression_level);
}

static ErrorOr<void> add_image_data_to_chunk(Gfx::Bitmap const& bitmap, PNG::ColorType color_type, PNGChunk& png_chunk, Compress::ZlibCompressionLevel compression_level)
{
    switch (color_type) {
    case PNG::ColorType::Greyscale:
        return add_image_data_to_chunk_impl<false, false>(bitmap, png_chunk, compression_level);
    case PNG::ColorType::Truecolor:
        return add_image_data_to_chunk_impl<false, true>(bitmap, png_chunk, compression_level);
    case PNG::ColorType::IndexedColor:
        VERIFY_NOT_REACHED();
    case PNG::ColorType::GreyscaleWithAlpha:
        return add_image_data_to_chunk_impl<true, false>(bitmap, png_chunk, compression_level);
    case PNG::ColorType::TruecolorWithAlpha:
        return add_image_data_to_chunk_impl<true, true>(bitmap, png_chunk, compression_level);
    }
    VERIFY_NOT_REACHED();
}

ErrorOr<void> PNGWriter::add_fdAT_chunk(Gfx::Bitmap const& bitmap, PNG::ColorType color_type, u32 sequence_number, Compress::ZlibCompressionLevel compression_level)
{
    // https://www.w3.org/TR/png/#fdAT-chunk
    PNGChunk png_chunk { "fdAT"_string };
    TRY(png_chunk.reserve(bitmap.size_in_bytes() + 4));
    TRY(png_chunk.add_as_big_endian(sequence_number));
    TRY(add_image_data_to_chunk(bitmap, color_type, png_chunk, compression_level));
    return add_chunk(png_chunk);
}

ErrorOr<void> PNGWriter::add_IDAT_chunk(Gfx::Bitmap const& bitmap, PNG::ColorType color_type, Compress::ZlibCompressionLevel compression_level)
{
    PNGChunk png_chunk { "IDAT"_string };
    TRY(png_chunk.reserve(bitmap.size_in_bytes()));
    TRY(add_image_data_to_chunk(bitmap, color_type, png_chunk, compression_level));
    return add_chunk(png_chunk);
}

static bool bitmap_has_transparency(Bitmap const& bitmap)
{
    for (auto pixel : bitmap) {
        if (Color::from_argb(pixel).alpha() != 255)
            return true;
    }
    return false;
}

static bool bitmap_has_color(Bitmap const& bitmap)
{
    for (auto pixel : bitmap) {
        auto color = Color::from_argb(pixel);
        if (color.red() != color.green() || color.green() != color.blue())
            return true;
    }
    return false;
}

static PNG::ColorType find_color_type(Bitmap const& bitmap, bool force_alpha)
{
    bool has_alpha = force_alpha || bitmap_has_transparency(bitmap);
    if (bitmap_has_color(bitmap)) {
        if (has_alpha)
            return PNG::ColorType::TruecolorWithAlpha;
        return PNG::ColorType::Truecolor;
    }
    if (has_alpha)
        return PNG::ColorType::GreyscaleWithAlpha;
    return PNG::ColorType::Greyscale;
}

ErrorOr<void> PNGWriter::encode(Stream& stream, Bitmap const& bitmap, Options const& options)
{
    PNGWriter writer { stream };
    TRY(writer.add_png_header());
    auto color_type = find_color_type(bitmap, options.force_alpha);
    TRY(writer.add_IHDR_chunk(bitmap.width(), bitmap.height(), 8, color_type, 0, 0, 0));
    if (options.icc_data.has_value())
        TRY(writer.add_iCCP_chunk(options.icc_data.value(), options.compression_level));
    TRY(writer.add_IDAT_chunk(bitmap, color_type, options.compression_level));
    TRY(writer.add_IEND_chunk());
    return {};
}

ErrorOr<ByteBuffer> PNGWriter::encode(Gfx::Bitmap const& bitmap, Options options)
{
    AllocatingMemoryStream stream;
    TRY(encode(stream, bitmap, options));
    return stream.read_until_eof();
}

class PNGAnimationWriter : public AnimationWriter {
public:
    PNGAnimationWriter(SeekableStream& stream, IntSize dimensions, int loop_count, PNGWriter::Options const& options)
        : m_writer(stream)
        , m_stream(stream)
        , m_dimensions(dimensions)
        , m_loop_count(loop_count)
        , m_options(options)
    {
    }

    virtual ErrorOr<void> add_frame(Bitmap&, int, IntPoint, BlendMode) override;
    virtual bool can_blend_frames() const override { return true; }

private:
    PNGWriter m_writer;
    SeekableStream& m_stream;

    IntSize const m_dimensions;
    int const m_loop_count { 0 };

    bool m_is_first_frame { true };

    u32 m_sequence_number { 0 };
    u32 m_number_of_frames { 0 };
    size_t m_acTL_offset { 0 };
    PNGWriter::Options const m_options;
};

ErrorOr<void> PNGAnimationWriter::add_frame(Bitmap& bitmap, int duration_ms, IntPoint at, BlendMode blend_mode)
{
    ++m_number_of_frames;
    bool const is_first_frame = m_number_of_frames == 1;

    if (is_first_frame) {
        // "The fcTL chunk corresponding to the default image, if it exists, has these restrictions:
        //  * The x_offset and y_offset fields must be 0.
        //  * The width and height fields must equal the corresponding fields from the IHDR chunk."
        // FIXME: If this ends up happening in practice, we should composite `bitmap` to a temporary bitmap and store that as first frame.
        if (at != IntPoint {})
            return Error::from_string_literal("First APNG frame must have x_offset and y_offset set to 0");
        if (bitmap.size() != m_dimensions)
            return Error::from_string_literal("First APNG frame must have the same dimensions as the APNG itself");

        // All frames in an APNG use the same IHDR chunk, which means they all have the same color type.
        // To decide if we should write RGB or RGBA, we'd really have to check all frames, but that needs a
        // lot of memory and makes streaming impossible.
        // Instead, we always include an alpha channel. In practice, inter-frame compression means that
        // even for animations without transparency, all but the first frame will have transparent pixels.
        // The APNG format doesn't give us super great options here.
        TRY(m_writer.add_png_header());
        TRY(m_writer.add_IHDR_chunk(m_dimensions.width(), m_dimensions.height(), 8, PNG::ColorType::TruecolorWithAlpha, 0, 0, 0));
        if (m_options.icc_data.has_value())
            TRY(m_writer.add_iCCP_chunk(m_options.icc_data.value(), m_options.compression_level));
        m_acTL_offset = TRY(m_stream.tell());
        TRY(m_writer.add_acTL_chunk(m_number_of_frames, m_loop_count));
    } else {
        // Overwrite previous acTL chunk to update its num_frames. Use add_acTL_chunk to make sure the chunk's crc is updated too.
        auto current_offset = TRY(m_stream.tell());
        TRY(m_stream.seek(m_acTL_offset, SeekMode::SetPosition));
        TRY(m_writer.add_acTL_chunk(m_number_of_frames, m_loop_count));
        TRY(m_stream.seek(current_offset, SeekMode::SetPosition));

        // Overwrite previous IEND marker.
        TRY(m_stream.seek(-12, SeekMode::FromCurrentPosition));
    }

    fcTLData fcTL_data;
    fcTL_data.sequence_number = m_sequence_number;
    fcTL_data.width = bitmap.width();
    fcTL_data.height = bitmap.height();
    fcTL_data.delay_numerator = duration_ms;
    fcTL_data.delay_denominator = 1000;
    fcTL_data.x_offset = at.x();
    fcTL_data.y_offset = at.y();
    if (blend_mode == BlendMode::Blend)
        fcTL_data.blend_operation = 1;
    TRY(m_writer.add_fcTL_chunk(fcTL_data));
    m_sequence_number++;

    if (is_first_frame) {
        TRY(m_writer.add_IDAT_chunk(bitmap, PNG::ColorType::TruecolorWithAlpha, m_options.compression_level));
    } else {
        TRY(m_writer.add_fdAT_chunk(bitmap, PNG::ColorType::TruecolorWithAlpha, m_sequence_number, m_options.compression_level));
        m_sequence_number++;
    }

    TRY(m_writer.add_IEND_chunk());

    return {};
}

ErrorOr<NonnullOwnPtr<AnimationWriter>> PNGWriter::start_encoding_animation(SeekableStream& stream, IntSize dimensions, int loop_count, Options const& options)
{
    auto writer = make<PNGAnimationWriter>(stream, dimensions, loop_count, options);
    return writer;
}

}