From ae18186905ee707ade1a25818a0cdad90368508d Mon Sep 17 00:00:00 2001 From: MacDue Date: Sun, 2 Jul 2023 22:24:01 +0100 Subject: [PATCH] LibGfx: Implement image decoder for TinyVG (.tvg) This adds a decoder for the TinyVG vector format (https://tinyvg.tech/). TinyVG is a very simple binary vector format, but it is good enough to represent a lot of SVGs, without needing the full web engine. The main use case for Serenity is for scalable icons (which .tvg easily handles). --- Userland/Libraries/LibGfx/CMakeLists.txt | 1 + .../LibGfx/ImageFormats/ImageDecoder.cpp | 2 + .../LibGfx/ImageFormats/TinyVGLoader.cpp | 535 ++++++++++++++++++ .../LibGfx/ImageFormats/TinyVGLoader.h | 102 ++++ 4 files changed, 640 insertions(+) create mode 100644 Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.cpp create mode 100644 Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.h diff --git a/Userland/Libraries/LibGfx/CMakeLists.txt b/Userland/Libraries/LibGfx/CMakeLists.txt index ab73aeb256..0208e347c0 100644 --- a/Userland/Libraries/LibGfx/CMakeLists.txt +++ b/Userland/Libraries/LibGfx/CMakeLists.txt @@ -49,6 +49,7 @@ set(SOURCES ImageFormats/QOILoader.cpp ImageFormats/QOIWriter.cpp ImageFormats/TGALoader.cpp + ImageFormats/TinyVGLoader.cpp ImageFormats/WebPLoader.cpp ImageFormats/WebPLoaderLossless.cpp ImageFormats/WebPLoaderLossy.cpp diff --git a/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp b/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp index 66c48df90a..d05a3db4c8 100644 --- a/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp +++ b/Userland/Libraries/LibGfx/ImageFormats/ImageDecoder.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace Gfx { @@ -39,6 +40,7 @@ static OwnPtr probe_and_sniff_for_appropriate_plugin(Readonl { JPEGImageDecoderPlugin::sniff, JPEGImageDecoderPlugin::create }, { DDSImageDecoderPlugin::sniff, DDSImageDecoderPlugin::create }, { QOIImageDecoderPlugin::sniff, QOIImageDecoderPlugin::create }, + { TinyVGImageDecoderPlugin::sniff, TinyVGImageDecoderPlugin::create }, { WebPImageDecoderPlugin::sniff, WebPImageDecoderPlugin::create }, }; diff --git a/Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.cpp b/Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.cpp new file mode 100644 index 0000000000..0e918ce919 --- /dev/null +++ b/Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.cpp @@ -0,0 +1,535 @@ +/* + * Copyright (c) 2023, MacDue + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Gfx { + +using VarUInt = LEB128; + +static constexpr Array TVG_MAGIC { 0x72, 0x56 }; + +enum class ColorEncoding : u8 { + RGBA8888 = 0, + RGB565 = 1, + RGBAF32 = 2, + Custom = 3 +}; + +enum class CoordinateRange : u8 { + Default = 0, + Reduced = 1, + Enhanced = 2 +}; + +enum class StyleType : u8 { + FlatColored = 0, + LinearGradient = 1, + RadialGradinet = 2 +}; + +enum class Command : u8 { + EndOfDocument = 0, + FillPolygon = 1, + FillRectangles = 2, + FillPath = 3, + DrawLines = 4, + DrawLineLoop = 5, + DrawLineStrip = 6, + DrawLinePath = 7, + OutlineFillPolygon = 8, + OutlineFillRectangles = 9, + OutLineFillPath = 10 +}; + +struct FillCommandHeader { + u32 count; + TinyVGDecodedImageData::Style style; +}; + +struct DrawCommandHeader { + u32 count; + TinyVGDecodedImageData::Style line_style; + float line_width; +}; + +struct OutlineFillCommandHeader { + u32 count; + TinyVGDecodedImageData::Style fill_style; + TinyVGDecodedImageData::Style line_style; + float line_width; +}; + +enum class PathCommand : u8 { + Line = 0, + HorizontalLine = 1, + VerticalLine = 2, + CubicBezier = 3, + ArcCircle = 4, + ArcEllipse = 5, + ClosePath = 6, + QuadraticBezier = 7 +}; + +struct TinyVGHeader { + u8 version; + u8 scale; + ColorEncoding color_encoding; + CoordinateRange coordinate_range; + u32 width; + u32 height; + u32 color_count; +}; + +static ErrorOr decode_tinyvg_header(Stream& stream) +{ + TinyVGHeader header {}; + Array magic_bytes; + TRY(stream.read_until_filled(magic_bytes)); + if (magic_bytes != TVG_MAGIC) + return Error::from_string_literal("Invalid TinyVG: Incorrect header magic"); + header.version = TRY(stream.read_value()); + u8 properties = TRY(stream.read_value()); + header.scale = properties & 0xF; + header.color_encoding = static_cast((properties >> 4) & 0x3); + header.coordinate_range = static_cast((properties >> 6) & 0x3); + switch (header.coordinate_range) { + case CoordinateRange::Default: + header.width = TRY(stream.read_value>()); + header.height = TRY(stream.read_value>()); + break; + case CoordinateRange::Reduced: + header.width = TRY(stream.read_value()); + header.height = TRY(stream.read_value()); + break; + case CoordinateRange::Enhanced: + header.width = TRY(stream.read_value>()); + header.height = TRY(stream.read_value>()); + break; + default: + return Error::from_string_literal("Invalid TinyVG: Bad coordinate range"); + } + header.color_count = TRY(stream.read_value()); + return header; +} + +static ErrorOr> decode_color_table(Stream& stream, ColorEncoding encoding, u32 color_count) +{ + if (encoding == ColorEncoding::Custom) + return Error::from_string_literal("Invalid TinyVG: Unsupported color encoding"); + auto color_table = TRY(FixedArray::create(color_count)); + auto parse_color = [&]() -> ErrorOr { + switch (encoding) { + case ColorEncoding::RGBA8888: { + Array rgba; + TRY(stream.read_until_filled(rgba)); + return Color(rgba[0], rgba[1], rgba[2], rgba[3]); + } + case ColorEncoding::RGB565: { + u16 color = TRY(stream.read_value>()); + auto red = (color >> (6 + 5)) & 0x1f; + auto green = (color >> 5) & 0x3f; + auto blue = (color >> 0) & 0x1f; + return Color((red * 255 + 15) / 31, (green * 255 + 31), (blue * 255 + 15) / 31); + } + case ColorEncoding::RGBAF32: { + auto red = TRY(stream.read_value>()); + auto green = TRY(stream.read_value>()); + auto blue = TRY(stream.read_value>()); + auto alpha = TRY(stream.read_value>()); + return Color(red * 255, green * 255, blue * 255, alpha * 255); + } + default: + return Error::from_string_literal("Invalid TinyVG: Bad color encoding"); + } + }; + for (auto& color : color_table) { + color = TRY(parse_color()); + } + return color_table; +} + +class TinyVGReader { +public: + TinyVGReader(Stream& stream, TinyVGHeader const& header, ReadonlySpan color_table) + : m_stream(stream) + , m_scale(powf(0.5, header.scale)) + , m_coordinate_range(header.coordinate_range) + , m_color_table(color_table) + { + } + + ErrorOr read_unit() + { + auto read_value = [&]() -> ErrorOr { + switch (m_coordinate_range) { + case CoordinateRange::Default: + return TRY(m_stream.read_value>()); + case CoordinateRange::Reduced: + return TRY(m_stream.read_value()); + case CoordinateRange::Enhanced: + return TRY(m_stream.read_value>()); + default: + // Note: Already checked while reading the header. + VERIFY_NOT_REACHED(); + } + }; + return TRY(read_value()) * m_scale; + } + + ErrorOr read_var_uint() + { + return TRY(m_stream.read_value()); + } + + ErrorOr read_point() + { + return FloatPoint { TRY(read_unit()), TRY(read_unit()) }; + } + + ErrorOr read_style(StyleType type) + { + auto read_color = [&]() -> ErrorOr { + auto color_index = TRY(m_stream.read_value()); + return m_color_table[color_index]; + }; + switch (type) { + case StyleType::FlatColored: { + return TRY(read_color()); + } + case StyleType::LinearGradient: + case StyleType::RadialGradinet: { + // TODO: Make PaintStyle (for these ultra basic gradients) + [[maybe_unused]] auto point_0 = TRY(read_point()); + [[maybe_unused]] auto point_1 = TRY(read_point()); + [[maybe_unused]] auto color_0 = TRY(read_color()); + [[maybe_unused]] auto color_1 = TRY(read_color()); + return Color(Color::Black); + } + } + return Error::from_string_literal("Invalid TinyVG: Bad style data"); + } + + ErrorOr read_rectangle() + { + return FloatRect { TRY(read_unit()), TRY(read_unit()), TRY(read_unit()), TRY(read_unit()) }; + } + + ErrorOr read_line() + { + return FloatLine { TRY(read_point()), TRY(read_point()) }; + } + + ErrorOr read_path(u32 segment_count) + { + Path path; + auto segment_lengths = TRY(FixedArray::create(segment_count)); + for (auto& command_count : segment_lengths) { + command_count = TRY(read_var_uint()) + 1; + } + for (auto command_count : segment_lengths) { + auto start_point = TRY(read_point()); + path.move_to(start_point); + for (u32 i = 0; i < command_count; i++) { + u8 command_tag = TRY(m_stream.read_value()); + auto path_command = static_cast(command_tag & 0x7); + switch (path_command) { + case PathCommand::Line: + path.line_to(TRY(read_point())); + break; + case PathCommand::HorizontalLine: + path.line_to({ TRY(read_unit()), path.segments().last()->point().y() }); + break; + case PathCommand::VerticalLine: + path.line_to({ path.segments().last()->point().x(), TRY(read_unit()) }); + break; + case PathCommand::CubicBezier: { + auto control_0 = TRY(read_point()); + auto control_1 = TRY(read_point()); + auto point_1 = TRY(read_point()); + path.cubic_bezier_curve_to(control_0, control_1, point_1); + break; + } + case PathCommand::ArcCircle: { + u8 flags = TRY(m_stream.read_value()); + bool large_arc = (flags >> 0) & 0b1; + bool sweep = (flags >> 1) & 0b1; + auto radius = TRY(read_unit()); + auto target = TRY(read_point()); + path.arc_to(target, radius, large_arc, !sweep); + break; + } + case PathCommand::ArcEllipse: { + u8 flags = TRY(m_stream.read_value()); + bool large_arc = (flags >> 0) & 0b1; + bool sweep = (flags >> 1) & 0b1; + auto radius_x = TRY(read_unit()); + auto radius_y = TRY(read_unit()); + auto rotation = TRY(read_unit()); + auto target = TRY(read_point()); + path.elliptical_arc_to(target, { radius_x, radius_y }, rotation, large_arc, !sweep); + break; + } + case PathCommand::ClosePath: { + path.close(); + break; + } + case PathCommand::QuadraticBezier: { + auto control = TRY(read_point()); + auto point_1 = TRY(read_point()); + path.quadratic_bezier_curve_to(control, point_1); + break; + } + default: + return Error::from_string_literal("Invalid TinyVG: Bad path command"); + } + } + } + return path; + } + + ErrorOr read_fill_command_header(StyleType style_type) + { + return FillCommandHeader { TRY(read_var_uint()) + 1, TRY(read_style(style_type)) }; + } + + ErrorOr read_draw_command_header(StyleType style_type) + { + return DrawCommandHeader { TRY(read_var_uint()) + 1, TRY(read_style(style_type)), TRY(read_unit()) }; + } + + ErrorOr read_outline_fill_command_header(StyleType style_type) + { + u8 header = TRY(m_stream.read_value()); + u8 count = (header & 0x3f) + 1; + auto stroke_type = static_cast((header >> 6) & 0x3); + return OutlineFillCommandHeader { count, TRY(read_style(style_type)), TRY(read_style(stroke_type)), TRY(read_unit()) }; + } + +private: + Stream& m_stream; + float m_scale {}; + CoordinateRange m_coordinate_range; + ReadonlySpan m_color_table; +}; + +ErrorOr TinyVGDecodedImageData::decode(Stream& stream) +{ + auto header = TRY(decode_tinyvg_header(stream)); + if (header.version != 1) + return Error::from_string_literal("Invalid TinyVG: Unsupported version"); + + auto color_table = TRY(decode_color_table(stream, header.color_encoding, header.color_count)); + TinyVGReader reader { stream, header, color_table.span() }; + + auto rectangle_to_path = [](FloatRect const& rect) -> Path { + Path path; + path.move_to({ rect.x(), rect.y() }); + path.line_to({ rect.x() + rect.width(), rect.y() }); + path.line_to({ rect.x() + rect.width(), rect.y() + rect.height() }); + path.line_to({ rect.x(), rect.y() + rect.height() }); + path.close(); + return path; + }; + + Vector draw_commands; + bool at_end = false; + while (!at_end) { + u8 command_info = TRY(stream.read_value()); + auto command = static_cast(command_info & 0x3f); + auto style_type = static_cast((command_info >> 6) & 0x3); + + switch (command) { + case Command::EndOfDocument: + at_end = true; + break; + case Command::FillPolygon: { + auto header = TRY(reader.read_fill_command_header(style_type)); + Path polygon; + polygon.move_to(TRY(reader.read_point())); + for (u32 i = 0; i < header.count - 1; i++) + polygon.line_to(TRY(reader.read_point())); + TRY(draw_commands.try_append(DrawCommand { move(polygon), move(header.style) })); + break; + } + case Command::FillRectangles: { + auto header = TRY(reader.read_fill_command_header(style_type)); + for (u32 i = 0; i < header.count; i++) { + TRY(draw_commands.try_append(DrawCommand { + rectangle_to_path(TRY(reader.read_rectangle())), move(header.style) })); + } + break; + } + case Command::FillPath: { + auto header = TRY(reader.read_fill_command_header(style_type)); + auto path = TRY(reader.read_path(header.count)); + TRY(draw_commands.try_append(DrawCommand { move(path), move(header.style) })); + break; + } + case Command::DrawLines: { + auto header = TRY(reader.read_draw_command_header(style_type)); + Path path; + for (u32 i = 0; i < header.count; i++) { + auto line = TRY(reader.read_line()); + path.move_to(line.a()); + path.line_to(line.b()); + } + TRY(draw_commands.try_append(DrawCommand { move(path), {}, move(header.line_style), header.line_width })); + break; + } + case Command::DrawLineStrip: + case Command::DrawLineLoop: { + auto header = TRY(reader.read_draw_command_header(style_type)); + Path path; + path.move_to(TRY(reader.read_point())); + for (u32 i = 0; i < header.count - 1; i++) + path.line_to(TRY(reader.read_point())); + if (command == Command::DrawLineLoop) + path.close(); + TRY(draw_commands.try_append(DrawCommand { move(path), {}, move(header.line_style), header.line_width })); + break; + } + case Command::DrawLinePath: { + auto header = TRY(reader.read_draw_command_header(style_type)); + auto path = TRY(reader.read_path(header.count)); + TRY(draw_commands.try_append(DrawCommand { move(path), {}, move(header.line_style), header.line_width })); + break; + } + case Command::OutlineFillPolygon: { + auto header = TRY(reader.read_outline_fill_command_header(style_type)); + Path polygon; + polygon.move_to(TRY(reader.read_point())); + for (u32 i = 0; i < header.count - 1; i++) + polygon.line_to(TRY(reader.read_point())); + TRY(draw_commands.try_append(DrawCommand { move(polygon), move(header.fill_style), move(header.line_style), header.line_width })); + break; + } + case Command::OutlineFillRectangles: { + auto header = TRY(reader.read_outline_fill_command_header(style_type)); + for (u32 i = 0; i < header.count - 1; i++) { + TRY(draw_commands.try_append(DrawCommand { + rectangle_to_path(TRY(reader.read_rectangle())), move(header.fill_style), move(header.line_style), header.line_width })); + } + break; + } + case Command::OutLineFillPath: { + auto header = TRY(reader.read_outline_fill_command_header(style_type)); + auto path = TRY(reader.read_path(header.count)); + TRY(draw_commands.try_append(DrawCommand { move(path), move(header.fill_style), move(header.line_style), header.line_width })); + break; + } + default: + return Error::from_string_literal("Invalid TinyVG: Bad command"); + } + } + + return TinyVGDecodedImageData { { header.width, header.height }, move(draw_commands) }; +} + +ErrorOr> TinyVGDecodedImageData::bitmap(IntSize size) const +{ + auto scale_x = float(size.width()) / m_size.width(); + auto scale_y = float(size.height()) / m_size.height(); + auto transform = Gfx::AffineTransform {}.scale(scale_x, scale_y); + auto bitmap = TRY(Bitmap::create(Gfx::BitmapFormat::BGRA8888, size)); + Painter base_painter { *bitmap }; + AntiAliasingPainter painter { base_painter }; + for (auto const& command : draw_commands()) { + auto draw_path = command.path.copy_transformed(transform); + if (command.fill.has_value()) { + auto fill_path = draw_path; + fill_path.close_all_subpaths(); + command.fill->visit( + [&](Color color) { painter.fill_path(fill_path, color, Painter::WindingRule::EvenOdd); }, + [&](NonnullRefPtr style) { + const_cast(*style).set_gradient_transform(transform); + painter.fill_path(fill_path, style, 1.0f, Painter::WindingRule::EvenOdd); + }); + } + if (command.stroke.has_value()) { + // FIXME: A more correct way to non-uniformly scale strokes would be: + // 1. Scale the path uniformly by the largest of scale_x/y + // 2. Convert that to a fill with .stroke_to_fill() + // 3. + // If scale_x > scale_y + // Scale by: (1, scale_y/scale_x) + // else + // Scale by: (scale_x/scale_y, 1) + auto stroke_scale = max(scale_x, scale_y); + command.stroke->visit( + [&](Color color) { painter.stroke_path(draw_path, color, command.stroke_width * stroke_scale); }, + [&](NonnullRefPtr style) { + const_cast(*style).set_gradient_transform(transform); + painter.stroke_path(draw_path, style, command.stroke_width * stroke_scale); + }); + } + } + return bitmap; +} + +TinyVGImageDecoderPlugin::TinyVGImageDecoderPlugin(ReadonlyBytes bytes) + : m_context { bytes } +{ +} + +ErrorOr> TinyVGImageDecoderPlugin::create(ReadonlyBytes bytes) +{ + return adopt_nonnull_own_or_enomem(new (nothrow) TinyVGImageDecoderPlugin(bytes)); +} + +bool TinyVGImageDecoderPlugin::sniff(ReadonlyBytes bytes) +{ + FixedMemoryStream stream { { bytes.data(), bytes.size() } }; + return !decode_tinyvg_header(stream).is_error(); +} + +IntSize TinyVGImageDecoderPlugin::size() +{ + if (m_context.decoded_image) + return m_context.decoded_image->size(); + return {}; +} + +void TinyVGImageDecoderPlugin::set_volatile() +{ + if (m_context.bitmap) + m_context.bitmap->set_volatile(); +} + +bool TinyVGImageDecoderPlugin::set_nonvolatile(bool& was_purged) +{ + if (!m_context.bitmap) + return false; + return m_context.bitmap->set_nonvolatile(was_purged); +} + +ErrorOr TinyVGImageDecoderPlugin::initialize() +{ + FixedMemoryStream stream { { m_context.data.data(), m_context.data.size() } }; + m_context.decoded_image = make(TRY(TinyVGDecodedImageData::decode(stream))); + return {}; +} + +ErrorOr TinyVGImageDecoderPlugin::frame(size_t, Optional ideal_size) +{ + auto target_size = ideal_size.value_or(m_context.decoded_image->size()); + if (!m_context.bitmap || m_context.bitmap->size() != target_size) + m_context.bitmap = TRY(m_context.decoded_image->bitmap(target_size)); + return ImageFrameDescriptor { m_context.bitmap }; +} + +} diff --git a/Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.h b/Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.h new file mode 100644 index 0000000000..087f120cd7 --- /dev/null +++ b/Userland/Libraries/LibGfx/ImageFormats/TinyVGLoader.h @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2023, MacDue + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Gfx { + +// Current recommended SVG to TVG conversion (without installing tools) +// (FIXME: Implement our own converter!) +// 1. (Optional) Convert strokes to fills +// * Only round joins/linecaps exist in TVG, so for other stroke kinds converting +// them to fills (that still beziers etc, so are scalable) works better. +// * This site can do that: https://iconly.io/tools/svg-convert-stroke-to-fill +// 2. Scale your SVG's width/height to large size (e.g. 1024x?) +// * Current converters deal poorly with small values in paths. +// * This site can do that: https://www.iloveimg.com/resize-image/resize-svg +// (or just edit the viewbox if it has one). +// 3. Convert the SVG to a TVG +// * This site can do that: https://svg-to-tvg-server.fly.dev/ + +// Decoder from the "Tiny Vector Graphics" format (v1.0). +// https://tinyvg.tech/download/specification.pdf + +class TinyVGDecodedImageData { +public: + using Style = Variant>; + + struct DrawCommand { + Path path; + Optional