diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b62b44..403b9901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add support for CSS gradients as background images + ## PlutoBook 0.19.0 (2026-08-04) - Fix several security vulnerabilities diff --git a/FEATURES.md b/FEATURES.md index 7a641202..0092dd1f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -3,6 +3,7 @@ - [Fonts](#fonts) - [Color](#color) - [Backgrounds and Borders](#backgrounds-and-borders) +- [Gradients](#gradients) - [Outlines](#outlines) - [Box Model](#box-model) - [Box Sizing](#box-sizing) @@ -36,10 +37,20 @@ PlutoBook also provides partial support for [CSS Color Module Level 4](https://w ## Backgrounds and Borders -PlutoBook implements the [CSS Backgrounds and Borders Module Level 3](https://www.w3.org/TR/css-backgrounds-3), providing control over the decoration of the border area and the background of the content, padding, and border areas. It supports properties like `background-color` for solid fills and `background-image` for adding images, along with `background-repeat`, `background-attachment`, `background-position`, `background-clip`, `background-origin`, and `background-size` for controlling placement and scaling. The `background` shorthand is fully supported, but multiple background layers are not yet supported. +PlutoBook implements the [CSS Backgrounds and Borders Module Level 3](https://www.w3.org/TR/css-backgrounds-3), providing control over the decoration of the border area and the background of the content, padding, and border areas. It supports properties like `background-color` for solid fills and `background-image` for adding images and [gradients](#gradients), along with `background-repeat`, `background-attachment`, `background-position`, `background-clip`, `background-origin`, and `background-size` for controlling placement and scaling. The `background` shorthand is fully supported, but multiple background layers are not yet supported. For borders, PlutoBook handles `border-color`, `border-style`, and `border-width`, which can be combined using the `border` shorthand. Side-specific border properties such as `border-top`, `border-right`, `border-bottom`, and `border-left` are also supported for precise control of each edge. The shape of corners can be adjusted using `border-radius`, allowing smooth rounding for any or all corners. +## Gradients + +PlutoBook supports CSS gradients as defined in the [CSS Images Module Level 3](https://www.w3.org/TR/css-images-3), which can be used anywhere an image is expected, such as `background-image` and `list-style-image`. All six gradient functions are available: `linear-gradient()`, `radial-gradient()`, `conic-gradient()`, and their `repeating-` counterparts. + +A linear gradient may be given an explicit `` or a `to ` keyword such as `to top right`. A radial gradient accepts an ending shape (`circle` or `ellipse`), a size given either as one of the `closest-side`, `closest-corner`, `farthest-side`, and `farthest-corner` keywords or as explicit radii, and a center position through `at `. A conic gradient accepts a starting angle through `from ` and a center position through `at `. + +Color stops support implicit positions, which spread evenly between their positioned neighbours, the two-position syntax (`red 20% 40%`), and transition hints (`red, 20%, blue`). Since a gradient is resolved against the style of the element using it, `currentColor` and font relative lengths such as `em` inside a gradient behave as expected. Gradients have no intrinsic dimensions, so they take the size of the area they are painted into and respond to `background-size`, `background-position`, and `background-repeat`. + +Gradients are emitted as PDF shadings rather than as bitmaps, so they stay resolution independent. PDF has no conic shading, so a conic gradient is approximated with a mesh of patches; the alternative of sampling the sweep into a bitmap can be selected at runtime with `plutobook::setConicGradientRendering()`, or `plutobook_set_conic_gradient_rendering()` from the C API. + ## Outlines PlutoBook supports outlines as defined in the [CSS Basic User Interface Module Level 3](https://www.w3.org/TR/css-ui-3). This includes `outline-color`, `outline-style`, `outline-width`, and `outline-offset`, allowing authors to draw a line around elements without affecting their box dimensions or layout flow. You can specify an outline’s color, style, and width using `outline-color`, `outline-style`, and `outline-width`, or conveniently set them all at once with the `outline` shorthand. The distance between the outline and the element’s border can be adjusted with `outline-offset`. Just like borders, outlines respect `border-radius`, so corners can be rounded consistently. Unlike borders, outlines do not take up space or influence the element’s size, making them useful for adding emphasis or separation without changing layout. diff --git a/include/plutobook.h b/include/plutobook.h index 417016c7..ba2da8d7 100644 --- a/include/plutobook.h +++ b/include/plutobook.h @@ -686,6 +686,35 @@ PLUTOBOOK_API void plutobook_set_http_max_redirects(int amount); */ PLUTOBOOK_API void plutobook_set_http_timeout(int timeout); +/** + * @brief Defines how a CSS conic gradient is rendered. + * + * Neither cairo nor PDF has a conic shading, so the sweep has to be + * approximated one way or another. + */ +typedef enum _plutobook_conic_gradient_rendering { + PLUTOBOOK_CONIC_GRADIENT_RENDERING_MESH, + PLUTOBOOK_CONIC_GRADIENT_RENDERING_RASTER +} plutobook_conic_gradient_rendering_t; + +/** + * @brief Selects the method used to render conic gradients. + * + * If not set, conic gradients are rendered with + * PLUTOBOOK_CONIC_GRADIENT_RENDERING_MESH, which keeps them resolution + * independent in the PDF output. PLUTOBOOK_CONIC_GRADIENT_RENDERING_RASTER + * trades that for an exact sweep, at the cost of an embedded bitmap. + * + * @param rendering The rendering method to use. + */ +PLUTOBOOK_API void plutobook_set_conic_gradient_rendering(plutobook_conic_gradient_rendering_t rendering); + +/** + * @brief Returns the method used to render conic gradients. + * @return The rendering method currently in use. + */ +PLUTOBOOK_API plutobook_conic_gradient_rendering_t plutobook_get_conic_gradient_rendering(void); + /** * @brief Defines the different media types used for CSS @media queries. */ diff --git a/include/plutobook.hpp b/include/plutobook.hpp index e4f790e0..166b7c76 100644 --- a/include/plutobook.hpp +++ b/include/plutobook.hpp @@ -634,6 +634,35 @@ class PLUTOBOOK_API DefaultResourceFetcher final : public ResourceFetcher { */ DefaultResourceFetcher* defaultResourceFetcher(); +/** + * @brief Defines how a CSS conic gradient is rendered. + * + * Neither cairo nor PDF has a conic shading, so the sweep has to be + * approximated one way or another. + */ +enum class ConicGradientRendering { + Mesh, ///< Approximate the sweep with a mesh of patches, which stays vectorial. + Raster ///< Sample the sweep into a bitmap. +}; + +/** + * @brief Selects the method used to render conic gradients. + * + * If not set, conic gradients are rendered with ConicGradientRendering::Mesh, + * which keeps them resolution independent in the PDF output. + * ConicGradientRendering::Raster trades that for an exact sweep, at the cost of + * an embedded bitmap. + * + * @param rendering The rendering method to use. + */ +PLUTOBOOK_API void setConicGradientRendering(ConicGradientRendering rendering); + +/** + * @brief Returns the method used to render conic gradients. + * @return The rendering method currently in use. + */ +PLUTOBOOK_API ConicGradientRendering conicGradientRendering(); + /** * @brief The OutputStream is an abstract base class for writing data to an output stream. */ diff --git a/meson.build b/meson.build index 006e2de1..95fd0b82 100644 --- a/meson.build +++ b/meson.build @@ -195,6 +195,7 @@ plutobook_sources = [ 'source/layout/textbox.cpp', 'source/resource/fontresource.cpp', + 'source/resource/gradientimage.cpp', 'source/resource/imageresource.cpp', 'source/resource/resource.cpp', 'source/resource/textresource.cpp', diff --git a/source/cssparser.cpp b/source/cssparser.cpp index a36ca966..3336065a 100644 --- a/source/cssparser.cpp +++ b/source/cssparser.cpp @@ -1918,10 +1918,239 @@ RefPtr CSSParser::consumeUrlOrNone(CSSTokenStream& input) return consumeUrl(input); } +bool CSSParser::consumeLinearGradientPrelude(CSSTokenStream& input, RefPtr& angle, RefPtr& direction) +{ + if((angle = consumeAngle(input))) + return true; + if(!consumeIdentIncludingWhitespace(input, "to")) + return true; + static constexpr CSSIdentValueEntry horizontalTable[] = { + {"left", CSSValueID::Left}, + {"right", CSSValueID::Right} + }; + + static constexpr CSSIdentValueEntry verticalTable[] = { + {"top", CSSValueID::Top}, + {"bottom", CSSValueID::Bottom} + }; + + RefPtr horizontal; + RefPtr vertical; + do { + if(horizontal == nullptr && (horizontal = consumeIdent(input, horizontalTable))) + continue; + if(vertical == nullptr && (vertical = consumeIdent(input, verticalTable))) + continue; + break; + } while(true); + + if(horizontal == nullptr && vertical == nullptr) + return false; + if(horizontal == nullptr) + horizontal = CSSIdentValue::create(CSSValueID::Center); + if(vertical == nullptr) + vertical = CSSIdentValue::create(CSSValueID::Center); + direction = CSSPairValue::create(m_heap, std::move(horizontal), std::move(vertical)); + return true; +} + +bool CSSParser::consumeRadialGradientPrelude(CSSTokenStream& input, RefPtr& shape, RefPtr& size, RefPtr& position) +{ + static constexpr CSSIdentValueEntry shapeTable[] = { + {"circle", CSSValueID::Circle}, + {"ellipse", CSSValueID::Ellipse} + }; + + static constexpr CSSIdentValueEntry sizeTable[] = { + {"closest-side", CSSValueID::ClosestSide}, + {"closest-corner", CSSValueID::ClosestCorner}, + {"farthest-side", CSSValueID::FarthestSide}, + {"farthest-corner", CSSValueID::FarthestCorner} + }; + + do { + if(shape == nullptr && (shape = consumeIdent(input, shapeTable))) + continue; + if(size == nullptr) { + if((size = consumeIdent(input, sizeTable))) + continue; + if(auto first = consumeLengthOrPercent(input, false, false)) { + if(auto second = consumeLengthOrPercent(input, false, false)) { + size = CSSPairValue::create(m_heap, std::move(first), std::move(second)); + } else { + size = std::move(first); + } + + continue; + } + } + + break; + } while(true); + + if(size && !is(*size)) { + auto ellipse = is(*size); + if(shape == nullptr) { + shape = CSSIdentValue::create(ellipse ? CSSValueID::Ellipse : CSSValueID::Circle); + } else if(ellipse != (shape->id() == CSSValueID::Ellipse)) { + return false; + } + + // A circle radius must be a length, percentages resolve against no single axis. + if(!ellipse && is(*size)) { + return false; + } + } + + if(consumeIdentIncludingWhitespace(input, "at")) { + position = consumePositionCoordinate(input); + if(position == nullptr) { + return false; + } + } + + return true; +} + +bool CSSParser::consumeConicGradientPrelude(CSSTokenStream& input, RefPtr& angle, RefPtr& position) +{ + if(consumeIdentIncludingWhitespace(input, "from")) { + angle = consumeAngle(input); + if(angle == nullptr) { + return false; + } + } + + if(consumeIdentIncludingWhitespace(input, "at")) { + position = consumePositionCoordinate(input); + if(position == nullptr) { + return false; + } + } + + return true; +} + +RefPtr CSSParser::consumeGradientStopPosition(CSSTokenStream& input, CSSGradientType gradientType) +{ + if(gradientType == CSSGradientType::Conic) { + if(auto value = consumeAngle(input)) + return value; + // A unitless zero is accepted wherever an angle is expected. + if(input->type() == CSSToken::Type::Number && input->number() == 0.f) { + input.consumeIncludingWhitespace(); + return CSSAngleValue::create(m_heap, 0.f, CSSAngleValue::Units::Degrees); + } + + return consumePercent(input, true); + } + + return consumeLengthOrPercent(input, true, false); +} + +bool CSSParser::consumeGradientStops(CSSTokenStream& input, CSSGradientType gradientType, CSSGradientStopList& stops) +{ + do { + auto color = consumeColor(input); + if(color == nullptr) { + // A color hint must sit between two color stops. + if(stops.empty()) + return false; + auto hint = consumeGradientStopPosition(input, gradientType); + if(hint == nullptr) + return false; + stops.emplace_back(nullptr, std::move(hint)); + if(!input.consumeCommaIncludingWhitespace()) + return false; + color = consumeColor(input); + if(color == nullptr) { + return false; + } + } + + if(auto first = consumeGradientStopPosition(input, gradientType)) { + auto second = consumeGradientStopPosition(input, gradientType); + stops.emplace_back(color, std::move(first)); + if(second) { + stops.emplace_back(std::move(color), std::move(second)); + } + } else { + stops.emplace_back(std::move(color), nullptr); + } + } while(input.consumeCommaIncludingWhitespace()); + + // A lone color stop is allowed and paints a solid color. + return true; +} + +RefPtr CSSParser::consumeGradient(CSSTokenStream& input, CSSGradientType gradientType, bool repeating) +{ + assert(input->type() == CSSToken::Type::Function); + CSSTokenStreamGuard guard(input); + auto block = input.consumeBlock(); + block.consumeWhitespace(); + + RefPtr angle; + RefPtr direction; + RefPtr shape; + RefPtr size; + RefPtr position; + switch(gradientType) { + case CSSGradientType::Linear: + if(!consumeLinearGradientPrelude(block, angle, direction)) + return nullptr; + break; + case CSSGradientType::Radial: + if(!consumeRadialGradientPrelude(block, shape, size, position)) + return nullptr; + break; + case CSSGradientType::Conic: + if(!consumeConicGradientPrelude(block, angle, position)) + return nullptr; + break; + } + + if(angle || direction || shape || size || position) { + if(!block.consumeCommaIncludingWhitespace()) { + return nullptr; + } + } + + CSSGradientStopList stops(m_heap); + if(!consumeGradientStops(block, gradientType, stops)) + return nullptr; + if(!block.empty()) + return nullptr; + input.consumeWhitespace(); + guard.release(); + return CSSGradientValue::create(m_heap, gradientType, repeating, std::move(angle), std::move(direction), + std::move(shape), std::move(size), std::move(position), std::move(stops)); +} + RefPtr CSSParser::consumeImage(CSSTokenStream& input) { if(auto token = consumeUrlToken(input)) return CSSImageValue::create(m_heap, m_context.completeUrl(token->data())); + if(input->type() == CSSToken::Type::Function) { + struct CSSGradientEntry { + CSSGradientType gradientType; + bool repeating; + }; + + static constexpr CSSIdentEntry table[] = { + {"linear-gradient", {CSSGradientType::Linear, false}}, + {"radial-gradient", {CSSGradientType::Radial, false}}, + {"conic-gradient", {CSSGradientType::Conic, false}}, + {"repeating-linear-gradient", {CSSGradientType::Linear, true}}, + {"repeating-radial-gradient", {CSSGradientType::Radial, true}}, + {"repeating-conic-gradient", {CSSGradientType::Conic, true}} + }; + + if(auto entry = matchIdent(table, input->data())) { + return consumeGradient(input, entry->gradientType, entry->repeating); + } + } + return nullptr; } diff --git a/source/cssparser.h b/source/cssparser.h index 935081a5..72e07e11 100644 --- a/source/cssparser.h +++ b/source/cssparser.h @@ -114,6 +114,8 @@ class CSSParser { RefPtr consumeUrlOrNone(CSSTokenStream& input); RefPtr consumeImage(CSSTokenStream& input); RefPtr consumeImageOrNone(CSSTokenStream& input); + RefPtr consumeGradient(CSSTokenStream& input, CSSGradientType gradientType, bool repeating); + RefPtr consumeGradientStopPosition(CSSTokenStream& input, CSSGradientType gradientType); RefPtr consumeColor(CSSTokenStream& input); RefPtr consumeRgb(CSSTokenStream& input); RefPtr consumeHsl(CSSTokenStream& input); @@ -163,6 +165,11 @@ class CSSParser { RefPtr consumePaintOrder(CSSTokenStream& input); RefPtr consumeLonghand(CSSTokenStream& input, CSSPropertyID id); + bool consumeLinearGradientPrelude(CSSTokenStream& input, RefPtr& angle, RefPtr& direction); + bool consumeRadialGradientPrelude(CSSTokenStream& input, RefPtr& shape, RefPtr& size, RefPtr& position); + bool consumeConicGradientPrelude(CSSTokenStream& input, RefPtr& angle, RefPtr& position); + bool consumeGradientStops(CSSTokenStream& input, CSSGradientType gradientType, CSSGradientStopList& stops); + bool consumeFlex(CSSTokenStream& input, CSSPropertyList& properties, bool important); bool consumeBackground(CSSTokenStream& input, CSSPropertyList& properties, bool important); bool consumeColumns(CSSTokenStream& input, CSSPropertyList& properties, bool important); diff --git a/source/cssproperty.h b/source/cssproperty.h index facc082a..36a5baac 100644 --- a/source/cssproperty.h +++ b/source/cssproperty.h @@ -368,6 +368,8 @@ enum class CSSValueID : uint16_t { Circle, Clip, CloseQuote, + ClosestCorner, + ClosestSide, Collapse, Color, ColorBurn, @@ -390,6 +392,7 @@ enum class CSSValueID : uint16_t { DiscretionaryLigatures, Dotted, Double, + Ellipse, Ellipsis, Embed, Emoji, @@ -400,6 +403,8 @@ enum class CSSValueID : uint16_t { Extends, ExtraCondensed, ExtraExpanded, + FarthestCorner, + FarthestSide, Fill, FitContent, Fixed, @@ -599,6 +604,7 @@ enum class CSSValueType { LocalUrl, Url, Image, + Gradient, Color, Counter, FontFeature, @@ -1179,6 +1185,79 @@ struct is_a { static bool check(const CSSValue& value) { return value.type() == CSSValueType::Image; } }; +enum class CSSGradientType { + Linear, + Radial, + Conic +}; + +class CSSGradientStop { +public: + CSSGradientStop(RefPtr color, RefPtr position) + : m_color(std::move(color)), m_position(std::move(position)) + {} + + const RefPtr& color() const { return m_color; } + const RefPtr& position() const { return m_position; } + + bool isHint() const { return m_color == nullptr; } + +private: + RefPtr m_color; + RefPtr m_position; +}; + +using CSSGradientStopList = std::pmr::vector; + +class CSSGradientValue final : public CSSValue { +public: + static RefPtr create(Heap* heap, CSSGradientType gradientType, bool repeating, + RefPtr angle, RefPtr direction, RefPtr shape, + RefPtr size, RefPtr position, CSSGradientStopList stops); + + CSSGradientType gradientType() const { return m_gradientType; } + bool repeating() const { return m_repeating; } + + const RefPtr& angle() const { return m_angle; } + const RefPtr& direction() const { return m_direction; } + const RefPtr& shape() const { return m_shape; } + const RefPtr& size() const { return m_size; } + const RefPtr& position() const { return m_position; } + const CSSGradientStopList& stops() const { return m_stops; } + CSSValueType type() const final { return CSSValueType::Gradient; } + +private: + CSSGradientValue(CSSGradientType gradientType, bool repeating, RefPtr angle, + RefPtr direction, RefPtr shape, RefPtr size, + RefPtr position, CSSGradientStopList stops) + : m_gradientType(gradientType), m_repeating(repeating), m_angle(std::move(angle)) + , m_direction(std::move(direction)), m_shape(std::move(shape)), m_size(std::move(size)) + , m_position(std::move(position)), m_stops(std::move(stops)) + {} + + CSSGradientType m_gradientType; + bool m_repeating; + RefPtr m_angle; + RefPtr m_direction; + RefPtr m_shape; + RefPtr m_size; + RefPtr m_position; + CSSGradientStopList m_stops; +}; + +inline RefPtr CSSGradientValue::create(Heap* heap, CSSGradientType gradientType, bool repeating, + RefPtr angle, RefPtr direction, RefPtr shape, + RefPtr size, RefPtr position, CSSGradientStopList stops) +{ + return adoptPtr(new (heap) CSSGradientValue(gradientType, repeating, std::move(angle), std::move(direction), + std::move(shape), std::move(size), std::move(position), std::move(stops))); +} + +template<> +struct is_a { + static bool check(const CSSValue& value) { return value.type() == CSSValueType::Gradient; } +}; + class CSSColorValue final : public CSSValue { public: static RefPtr create(Heap* heap, const Color& value); diff --git a/source/graphics/color.cpp b/source/graphics/color.cpp index 2e2f32d8..be20f844 100644 --- a/source/graphics/color.cpp +++ b/source/graphics/color.cpp @@ -18,6 +18,24 @@ const Color Color::Transparent(0x00000000); const Color Color::Black(0xFF000000); const Color Color::White(0xFFFFFFFF); +Color interpolateColor(const Color& from, const Color& to, float t) +{ + auto fromAlpha = from.alpha() / 255.f; + auto toAlpha = to.alpha() / 255.f; + auto alpha = fromAlpha + (toAlpha - fromAlpha) * t; + auto blend = [&](uint8_t fromValue, uint8_t toValue) { + auto fromPremultiplied = fromValue / 255.f * fromAlpha; + auto toPremultiplied = toValue / 255.f * toAlpha; + auto value = fromPremultiplied + (toPremultiplied - fromPremultiplied) * t; + if(alpha > 0.f) + value /= alpha; + return static_cast(std::lround(std::clamp(value, 0.f, 1.f) * 255.f)); + }; + + return Color(blend(from.red(), to.red()), blend(from.green(), to.green()), blend(from.blue(), to.blue()), + static_cast(std::lround(std::clamp(alpha, 0.f, 1.f) * 255.f))); +} + Color Color::lighten() const { if(m_value == 0xFF000000) { diff --git a/source/graphics/color.h b/source/graphics/color.h index 71c2f6ce..cbe8e82a 100644 --- a/source/graphics/color.h +++ b/source/graphics/color.h @@ -54,6 +54,10 @@ constexpr Color Color::colorWithAlpha(float opacity) const return Color(rgb | a << 24); } +// Blends two colors in premultiplied sRGB, as CSS requires of gradients, so +// that a ramp towards a transparent color does not pick up its hue. +Color interpolateColor(const Color& from, const Color& to, float t); + constexpr bool operator==(const Color& a, const Color& b) { return a.value() == b.value(); diff --git a/source/graphics/graphicscontext.cpp b/source/graphics/graphicscontext.cpp index d897bd0d..ecbbc9d3 100644 --- a/source/graphics/graphicscontext.cpp +++ b/source/graphics/graphicscontext.cpp @@ -8,6 +8,7 @@ #include "graphicscontext.h" #include "geometry.h" +#include "plutobook.hpp" #include @@ -185,12 +186,234 @@ void GraphicsContext::setLinearGradient(const LinearGradientValues& values, cons void GraphicsContext::setRadialGradient(const RadialGradientValues& values, const GradientStops& stops, const Transform& transform, SpreadMethod method, float opacity) { - auto pattern = cairo_pattern_create_radial(values.fx, values.fy, 0, values.cx, values.cy, values.r); + auto pattern = cairo_pattern_create_radial(values.fx, values.fy, values.r0, values.cx, values.cy, values.r); set_cairo_gradient(pattern, stops, transform, method, opacity); cairo_set_source(m_canvas, pattern); cairo_pattern_destroy(pattern); } +// A conic sweep is cut into sectors no wider than this, which keeps the +// piecewise linear approximation of the ramp below the visible threshold. +constexpr float kConicSectorTurn = 1.f / 48.f; + +// Upper bound on the number of stop boundaries materialized for a repeating +// sweep, so that a tiny period does not explode into thousands of patches. +constexpr size_t kMaxConicBoundaries = 512; + +// Upper bound on the side of the bitmap a rasterized sweep is sampled into. +constexpr int kMaxConicRasterSize = 2048; + +static ConicGradientRendering g_conicGradientRendering = ConicGradientRendering::Mesh; + +void setConicGradientRendering(ConicGradientRendering rendering) +{ + g_conicGradientRendering = rendering; +} + +ConicGradientRendering conicGradientRendering() +{ + return g_conicGradientRendering; +} + +static Color conic_gradient_color_at(const GradientStops& stops, float offset, SpreadMethod method) +{ + auto first = stops.front().first; + auto last = stops.back().first; + if(method == SpreadMethod::Repeat) { + auto span = last - first; + offset = first + std::fmod(std::fmod(offset - first, span) + span, span); + } + + if(offset <= first) + return stops.front().second; + if(offset >= last) + return stops.back().second; + size_t index = 1; + while(stops[index].first < offset) + ++index; + const auto& from = stops[index - 1]; + const auto& to = stops[index]; + if(to.first <= from.first) + return to.second; + return interpolateColor(from.second, to.second, (offset - from.first) / (to.first - from.first)); +} + +static std::vector conic_gradient_boundaries(const GradientStops& stops, SpreadMethod method) +{ + std::vector offsets = {0.f, 1.f}; + auto first = stops.front().first; + auto last = stops.back().first; + if(method == SpreadMethod::Repeat) { + auto span = last - first; + auto begin = std::floor(-first / span); + auto end = std::ceil((1.f - first) / span); + + // The period count is tested before it is narrowed: a nearly zero span + // pushes the bounds far past what an int can hold, and converting that + // is undefined. A non-finite count fails the comparison and is skipped. + auto periodCount = (end - begin + 1.f) * stops.size(); + if(periodCount > 0.f && periodCount <= kMaxConicBoundaries) { + for(int period = static_cast(begin); period <= static_cast(end); ++period) { + for(const auto& stop : stops) { + auto offset = stop.first + period * span; + if(offset > 0.f && offset < 1.f) { + offsets.push_back(offset); + } + } + } + } + } else { + for(const auto& stop : stops) { + if(stop.first > 0.f && stop.first < 1.f) { + offsets.push_back(stop.first); + } + } + } + + std::sort(offsets.begin(), offsets.end()); + + std::vector boundaries = {offsets.front()}; + for(size_t index = 1; index < offsets.size(); ++index) { + auto from = boundaries.back(); + auto to = offsets[index]; + if(to <= from) + continue; + auto count = static_cast(std::ceil((to - from) / kConicSectorTurn)); + for(int step = 1; step <= count; ++step) { + boundaries.push_back(from + (to - from) * step / count); + } + } + + return boundaries; +} + +static void add_conic_gradient_patch(cairo_pattern_t* pattern, const ConicGradientValues& values, float fromOffset, float toOffset, const Color& fromColor, const Color& toColor, float opacity) +{ + // A zero turn points up and the sweep runs clockwise, which in device + // space, with the y axis growing downwards, is the increasing direction. + auto fromAngle = deg2rad(values.angle) + fromOffset * 2.f * kPi - kPi / 2.f; + auto toAngle = deg2rad(values.angle) + toOffset * 2.f * kPi - kPi / 2.f; + + auto fromX = values.cx + values.r * std::cos(fromAngle); + auto fromY = values.cy + values.r * std::sin(fromAngle); + auto toX = values.cx + values.r * std::cos(toAngle); + auto toY = values.cy + values.r * std::sin(toAngle); + + auto handle = values.r * 4.f / 3.f * std::tan((toAngle - fromAngle) / 4.f); + + cairo_mesh_pattern_begin_patch(pattern); + cairo_mesh_pattern_move_to(pattern, values.cx, values.cy); + cairo_mesh_pattern_line_to(pattern, fromX, fromY); + cairo_mesh_pattern_curve_to(pattern, + fromX - handle * std::sin(fromAngle), fromY + handle * std::cos(fromAngle), + toX + handle * std::sin(toAngle), toY - handle * std::cos(toAngle), + toX, toY); + cairo_mesh_pattern_line_to(pattern, values.cx, values.cy); + + // The two corners of a radial edge share a color, so the patch only + // interpolates along the arc. + const Color corners[4] = {fromColor, fromColor, toColor, toColor}; + for(unsigned int corner = 0; corner < 4; ++corner) { + const auto& color = corners[corner]; + cairo_mesh_pattern_set_corner_color_rgba(pattern, corner, color.red() / 255.0, + color.green() / 255.0, color.blue() / 255.0, color.alpha() / 255.0 * opacity); + } + + cairo_mesh_pattern_end_patch(pattern); +} + +static cairo_pattern_t* create_conic_gradient_mesh(const ConicGradientValues& values, const GradientStops& stops, SpreadMethod method, float opacity) +{ + auto pattern = cairo_pattern_create_mesh(); + const auto boundaries = conic_gradient_boundaries(stops, method); + for(size_t index = 1; index < boundaries.size(); ++index) { + auto fromOffset = boundaries[index - 1]; + auto toOffset = boundaries[index]; + + // Sample just inside the sector so that two stops sharing a position + // still produce the hard transition they describe. + auto epsilon = (toOffset - fromOffset) / 1000.f; + add_conic_gradient_patch(pattern, values, fromOffset, toOffset, + conic_gradient_color_at(stops, fromOffset + epsilon, method), + conic_gradient_color_at(stops, toOffset - epsilon, method), opacity); + } + + return pattern; +} + +static cairo_pattern_t* create_conic_gradient_raster(cairo_t* canvas, const ConicGradientValues& values, const GradientStops& stops, SpreadMethod method, float opacity) +{ + cairo_matrix_t canvasMatrix; + cairo_get_matrix(canvas, &canvasMatrix); + auto deviceScale = std::max(std::hypot(canvasMatrix.xx, canvasMatrix.yx), std::hypot(canvasMatrix.xy, canvasMatrix.yy)); + + auto diameter = 2.f * values.r; + + // Clamped before narrowing, since a large radius or device scale would + // otherwise overflow the conversion. A non-finite side fails both + // comparisons and settles on the upper bound. + auto side = std::ceil(diameter * deviceScale); + auto resolution = kMaxConicRasterSize; + if(side < 1.f) { + resolution = 1; + } else if(side < static_cast(kMaxConicRasterSize)) { + resolution = static_cast(side); + } + auto buffer = ImageBuffer::create(0, 0, resolution, resolution); + + auto surface = buffer->surface(); + cairo_surface_flush(surface); + + auto data = cairo_image_surface_get_data(surface); + auto stride = cairo_image_surface_get_stride(surface); + auto step = diameter / resolution; + for(int y = 0; y < resolution; ++y) { + auto row = reinterpret_cast(data + y * stride); + auto dy = (y + 0.5f) * step - values.r; + for(int x = 0; x < resolution; ++x) { + auto dx = (x + 0.5f) * step - values.r; + auto turn = (std::atan2(dy, dx) + kPi / 2.f - deg2rad(values.angle)) / (2.f * kPi); + auto color = conic_gradient_color_at(stops, turn - std::floor(turn), method); + auto alpha = color.alpha() / 255.f * opacity; + auto premultiply = [&](uint8_t value) { return static_cast(std::lround(value * alpha)); }; + row[x] = static_cast(std::lround(alpha * 255.f)) << 24 | premultiply(color.red()) << 16 + | premultiply(color.green()) << 8 | premultiply(color.blue()); + } + } + + cairo_surface_mark_dirty(surface); + + auto pattern = cairo_pattern_create_for_surface(surface); + cairo_pattern_set_extend(pattern, CAIRO_EXTEND_PAD); + cairo_pattern_set_filter(pattern, CAIRO_FILTER_BILINEAR); + + cairo_matrix_t patternMatrix; + cairo_matrix_init_scale(&patternMatrix, resolution / diameter, resolution / diameter); + cairo_matrix_translate(&patternMatrix, values.r - values.cx, values.r - values.cy); + cairo_pattern_set_matrix(pattern, &patternMatrix); + return pattern; +} + +void GraphicsContext::setConicGradient(const ConicGradientValues& values, const GradientStops& stops, const Transform& transform, SpreadMethod method, float opacity) +{ + cairo_pattern_t* pattern = nullptr; + if(conicGradientRendering() == ConicGradientRendering::Raster) { + pattern = create_conic_gradient_raster(m_canvas, values, stops, method, opacity); + } else { + pattern = create_conic_gradient_mesh(values, stops, method, opacity); + } + + cairo_matrix_t matrix; + cairo_pattern_get_matrix(pattern, &matrix); + + auto userMatrix = to_cairo_matrix(transform); + cairo_matrix_invert(&userMatrix); + cairo_matrix_multiply(&matrix, &userMatrix, &matrix); + cairo_pattern_set_matrix(pattern, &matrix); + cairo_set_source(m_canvas, pattern); + cairo_pattern_destroy(pattern); +} + void GraphicsContext::setPattern(cairo_surface_t* surface, const Transform& transform) { auto pattern = cairo_pattern_create_for_surface(surface); diff --git a/source/graphics/graphicscontext.h b/source/graphics/graphicscontext.h index 68c6243d..714f5922 100644 --- a/source/graphics/graphicscontext.h +++ b/source/graphics/graphicscontext.h @@ -37,6 +37,14 @@ struct RadialGradientValues { float cx = 0.f; float cy = 0.f; float r = 0.f; + float r0 = 0.f; +}; + +struct ConicGradientValues { + float cx = 0.f; + float cy = 0.f; + float r = 0.f; + float angle = 0.f; }; enum class SpreadMethod { @@ -91,6 +99,12 @@ class GraphicsContext { void setColor(const Color& color); void setLinearGradient(const LinearGradientValues& values, const GradientStops& stops, const Transform& transform, SpreadMethod method, float opacity); void setRadialGradient(const RadialGradientValues& values, const GradientStops& stops, const Transform& transform, SpreadMethod method, float opacity); + + // Cairo has no conic gradient primitive, so the sweep is approximated with + // a mesh of circular sectors, which stays vectorial in the PDF output. + // Stop offsets are expressed in turns and |values.angle| is the angle the + // sweep starts from, in degrees, zero pointing up and growing clockwise. + void setConicGradient(const ConicGradientValues& values, const GradientStops& stops, const Transform& transform, SpreadMethod method, float opacity); void setPattern(cairo_surface_t* surface, const Transform& transform); void translate(float tx, float ty); diff --git a/source/layout/boxstyle.cpp b/source/layout/boxstyle.cpp index 6eb7e94f..3701a7d5 100644 --- a/source/layout/boxstyle.cpp +++ b/source/layout/boxstyle.cpp @@ -10,6 +10,7 @@ #include "cssproperty.h" #include "document.h" #include "imageresource.h" +#include "gradientimage.h" #include "fontresource.h" #include "localedata.h" #include "geometry.h" @@ -2283,6 +2284,19 @@ Paint BoxStyle::convertPaint(const CSSValue& value) const RefPtr BoxStyle::convertImage(const CSSValue& value) const { + if(is(value)) { + // A gradient image is derived from this style, so it cannot be cached + // on the shared value the way a fetched one is. Memoize it here + // instead: painting asks for it once per box per page, and the heap + // only reclaims at document teardown. + if(m_gradientImages == nullptr) + m_gradientImages = std::make_unique(heap()); + auto& image = (*m_gradientImages)[&value]; + if(image == nullptr) + image = GradientImage::create(*this, to(value)); + return image; + } + return to(value).fetch(document()); } diff --git a/source/layout/boxstyle.h b/source/layout/boxstyle.h index 8c46cb17..f0df667c 100644 --- a/source/layout/boxstyle.h +++ b/source/layout/boxstyle.h @@ -650,6 +650,7 @@ class CSSVariableData; using CSSPropertyMap = std::pmr::unordered_map>; using CSSCustomPropertyMap = std::pmr::map, std::less<>>; +using GradientImageMap = std::pmr::unordered_map>; enum class PseudoType : uint8_t { None, @@ -1046,6 +1047,10 @@ class BoxStyle : public HeapMember, public RefCounted { const BoxStyle* m_parentStyle; CSSPropertyMap m_properties; std::unique_ptr m_customProperties; + + // A gradient image is derived from this style, so it is memoized per + // gradient value rather than rebuilt on every paint. + mutable std::unique_ptr m_gradientImages; RefPtr m_font; Display m_display : 5; diff --git a/source/layout/contentbox.cpp b/source/layout/contentbox.cpp index cbacc55c..7c5bf7a1 100644 --- a/source/layout/contentbox.cpp +++ b/source/layout/contentbox.cpp @@ -9,6 +9,7 @@ #include "contentbox.h" #include "replacedbox.h" #include "imageresource.h" +#include "gradientimage.h" #include "htmldocument.h" #include "cssrule.h" #include "counters.h" @@ -242,6 +243,8 @@ void ContentBoxBuilder::build(const CSSValue& content) addText(string->value()); } else if(auto image = to(value)) { addImage(image->fetch(m_style->document())); + } else if(auto gradient = to(value)) { + addImage(GradientImage::create(*m_style, *gradient)); } else if(auto counter = to(value)) { addCounter(*counter); } else if(auto ident = to(value)) { diff --git a/source/plutobook.cc b/source/plutobook.cc index f5531ebb..d687b79e 100644 --- a/source/plutobook.cc +++ b/source/plutobook.cc @@ -491,6 +491,19 @@ void plutobook_set_http_timeout(int timeout) plutobook::defaultResourceFetcher()->setTimeout(timeout); } +void plutobook_set_conic_gradient_rendering(plutobook_conic_gradient_rendering_t rendering) +{ + plutobook::setConicGradientRendering(rendering == PLUTOBOOK_CONIC_GRADIENT_RENDERING_RASTER + ? plutobook::ConicGradientRendering::Raster : plutobook::ConicGradientRendering::Mesh); +} + +plutobook_conic_gradient_rendering_t plutobook_get_conic_gradient_rendering(void) +{ + if(plutobook::conicGradientRendering() == plutobook::ConicGradientRendering::Raster) + return PLUTOBOOK_CONIC_GRADIENT_RENDERING_RASTER; + return PLUTOBOOK_CONIC_GRADIENT_RENDERING_MESH; +} + struct _plutobook final : public plutobook::Book, public plutobook::ResourceFetcher { _plutobook(plutobook_page_size_t size, plutobook_page_margins_t margins, plutobook_media_type_t media); plutobook::ResourceData fetchUrl(const std::string& url) final; diff --git a/source/resource/gradientimage.cpp b/source/resource/gradientimage.cpp new file mode 100644 index 00000000..b5f80de9 --- /dev/null +++ b/source/resource/gradientimage.cpp @@ -0,0 +1,483 @@ +/* + * Copyright (c) 2022-2026 Samuel Ugochukwu + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +#include "gradientimage.h" +#include "boxstyle.h" + +#include + +#include +#include + +namespace plutobook { + +// The CSS gradient line is described by a fraction of its total length; a +// gradient whose stops all coincide is turned into a hard transition over +// this fraction, which is small enough to be invisible at any scale. +constexpr float kHardTransitionSpan = 0.0001f; + +// Number of samples used to approximate a transition hint. Cairo has no +// notion of a color midpoint, so the non linear ramp it describes has to be +// flattened into ordinary stops. +constexpr int kHintSampleCount = 12; + +// Number of samples used between two stops of differing opacity. +constexpr int kAlphaSampleCount = 12; + +static void appendHintStops(GradientStops& stops, float fromOffset, const Color& fromColor, float hintOffset, float toOffset, const Color& toColor) +{ + auto span = toOffset - fromOffset; + if(span <= 0.f) + return; + auto ratio = (hintOffset - fromOffset) / span; + if(ratio <= 0.f || ratio >= 1.f) { + // A hint sitting on either end collapses the ramp onto one color. + stops.emplace_back(hintOffset, ratio <= 0.f ? toColor : fromColor); + return; + } + + if(std::abs(ratio - 0.5f) < 0.001f) + return; + auto exponent = std::log(0.5f) / std::log(ratio); + for(int index = 1; index < kHintSampleCount; ++index) { + auto position = static_cast(index) / kHintSampleCount; + stops.emplace_back(fromOffset + span * position, interpolateColor(fromColor, toColor, std::pow(position, exponent))); + } +} + +void GradientImage::buildColorStops(float lineLength, GradientStops& stops) const +{ + const auto count = m_stops.size(); + std::vector offsets(count, 0.f); + std::vector resolved(count, false); + for(size_t index = 0; index < count; ++index) { + const auto& position = m_stops[index].position; + if(position.isAuto()) + continue; + if(position.isPercent()) { + offsets[index] = position.value() / 100.f; + } else if(lineLength > 0.f) { + offsets[index] = position.value() / lineLength; + } + + resolved[index] = true; + } + + if(!resolved.front()) { + offsets.front() = 0.f; + resolved.front() = true; + } + + if(!resolved.back()) { + offsets.back() = 1.f; + resolved.back() = true; + } + + // Stop positions may not decrease; a smaller one is pulled up to its + // predecessor, which is what produces a hard color transition. + auto maximum = offsets.front(); + for(size_t index = 0; index < count; ++index) { + if(resolved[index]) { + maximum = std::max(maximum, offsets[index]); + offsets[index] = maximum; + } + } + + // Runs of stops without a position spread evenly between the surrounding + // positioned ones. + for(size_t index = 1; index < count; ++index) { + if(resolved[index]) + continue; + size_t next = index; + while(!resolved[next]) + ++next; + for(size_t current = index; current < next; ++current) + offsets[current] = offsets[index - 1] + (offsets[next] - offsets[index - 1]) * (current - index + 1) / (next - index + 1); + index = next; + } + + GradientStops resolvedStops; + for(size_t index = 0; index < count; ++index) { + if(m_stops[index].hint) + continue; + if(index >= 2 && m_stops[index - 1].hint) { + appendHintStops(resolvedStops, offsets[index - 2], m_stops[index - 2].color, offsets[index - 1], offsets[index], m_stops[index].color); + } + + resolvedStops.emplace_back(offsets[index], m_stops[index].color); + } + + // Cairo blends between two stops without premultiplying their alpha, so + // a ramp towards a translucent color has to be sampled by hand to avoid + // the color of that stop bleeding into the transparent end. + for(size_t index = 0; index < resolvedStops.size(); ++index) { + if(index > 0) { + const auto& from = resolvedStops[index - 1]; + const auto& to = resolvedStops[index]; + if(from.second.alpha() != to.second.alpha() && from.first < to.first) { + for(int sample = 1; sample < kAlphaSampleCount; ++sample) { + auto position = static_cast(sample) / kAlphaSampleCount; + stops.emplace_back(from.first + (to.first - from.first) * position, interpolateColor(from.second, to.second, position)); + } + } + } + + stops.push_back(resolvedStops[index]); + } +} + +static void clampColorStops(GradientStops& stops) +{ + if(stops.front().first >= 0.f) + return; + size_t index = 0; + while(index + 1 < stops.size() && stops[index + 1].first <= 0.f) + ++index; + if(index + 1 == stops.size()) { + // The whole ramp lies before the origin, only its end is visible. + auto color = stops.back().second; + stops.clear(); + stops.emplace_back(0.f, color); + stops.emplace_back(1.f, color); + return; + } + + const auto& from = stops[index]; + const auto& to = stops[index + 1]; + auto color = interpolateColor(from.second, to.second, -from.first / (to.first - from.first)); + stops.erase(stops.begin(), stops.begin() + index + 1); + stops.insert(stops.begin(), GradientStop(0.f, color)); +} + +GradientImage::ResolvedGradient GradientImage::resolveGradient(float lineLength, bool positiveOnly) const +{ + ResolvedGradient gradient; + buildColorStops(lineLength, gradient.stops); + if(positiveOnly && !m_repeating) + clampColorStops(gradient.stops); + + auto first = gradient.stops.front().first; + auto last = gradient.stops.back().first; + auto span = last - first; + if(span <= 0.f) { + if(m_repeating) { + // A repeating ramp with no length has no period to repeat. + gradient.color = gradient.stops.back().second; + gradient.degenerate = true; + return gradient; + } + + gradient.start = std::max(first, 0.f); + gradient.end = gradient.start + kHardTransitionSpan; + + GradientStops stops; + stops.emplace_back(0.f, gradient.stops.front().second); + stops.emplace_back(1.f, gradient.stops.back().second); + gradient.stops = std::move(stops); + return gradient; + } + + if(positiveOnly && first < 0.f) { + // A repeating ramp is periodic, so sliding it by whole periods keeps + // the rendering identical while moving it onto the positive ray. + auto shift = std::ceil(-first / span) * span; + for(auto& stop : gradient.stops) + stop.first += shift; + first += shift; + last += shift; + } + + // Cairo clamps stop offsets to [0, 1], so the gradient geometry is moved + // onto the span actually covered by the stops instead. + for(auto& stop : gradient.stops) + stop.first = (stop.first - first) / span; + gradient.start = first; + gradient.end = last; + gradient.method = m_repeating ? SpreadMethod::Repeat : SpreadMethod::Pad; + return gradient; +} + +static float directionAngle(CSSValueID horizontal, CSSValueID vertical, float width, float height) +{ + if(horizontal == CSSValueID::Center) + return vertical == CSSValueID::Top ? 0.f : 180.f; + if(vertical == CSSValueID::Center) + return horizontal == CSSValueID::Right ? 90.f : 270.f; + // The gradient line of a corner keyword is perpendicular to the diagonal + // joining the two neighbouring corners. + auto angle = rad2deg(std::atan2(height, width)); + if(vertical == CSSValueID::Top) + return horizontal == CSSValueID::Right ? angle : 360.f - angle; + return horizontal == CSSValueID::Right ? 180.f - angle : 180.f + angle; +} + +void GradientImage::applyLinearGradient(GraphicsContext& context) const +{ + auto width = m_containerSize.w; + auto height = m_containerSize.h; + auto angle = m_angle; + if(m_directionX != CSSValueID::Unknown) + angle = directionAngle(m_directionX, m_directionY, width, height); + auto radians = deg2rad(angle); + auto dx = std::sin(radians); + auto dy = -std::cos(radians); + + // An angle of 0deg points to the top and grows clockwise; the line is + // long enough for the box corners to fall on the 0% and 100% stops. + auto lineLength = std::abs(width * dx) + std::abs(height * dy); + auto gradient = resolveGradient(lineLength, false); + if(gradient.degenerate) { + context.setColor(gradient.color); + return; + } + + Point origin(width / 2.f - dx * lineLength / 2.f, height / 2.f - dy * lineLength / 2.f); + + LinearGradientValues values; + values.x1 = origin.x + dx * lineLength * gradient.start; + values.y1 = origin.y + dy * lineLength * gradient.start; + values.x2 = origin.x + dx * lineLength * gradient.end; + values.y2 = origin.y + dy * lineLength * gradient.end; + context.setLinearGradient(values, gradient.stops, Transform(), gradient.method, 1.f); +} + +void GradientImage::applyRadialGradient(GraphicsContext& context) const +{ + auto width = m_containerSize.w; + auto height = m_containerSize.h; + + Point center(m_position.x().calc(width), m_position.y().calc(height)); + auto left = std::abs(center.x); + auto right = std::abs(width - center.x); + auto top = std::abs(center.y); + auto bottom = std::abs(height - center.y); + + float radiusX = 0.f; + float radiusY = 0.f; + switch(m_sizeType) { + case CSSValueID::ClosestSide: + case CSSValueID::ClosestCorner: + radiusX = std::min(left, right); + radiusY = std::min(top, bottom); + break; + case CSSValueID::FarthestSide: + case CSSValueID::FarthestCorner: + radiusX = std::max(left, right); + radiusY = std::max(top, bottom); + break; + default: + radiusX = m_radiusX.calc(width); + radiusY = m_radiusY.calc(height); + break; + } + + if(m_sizeType == CSSValueID::ClosestSide || m_sizeType == CSSValueID::FarthestSide) { + if(m_circle) { + radiusX = radiusY = m_sizeType == CSSValueID::ClosestSide + ? std::min(radiusX, radiusY) : std::max(radiusX, radiusY); + } + } else if(m_sizeType == CSSValueID::ClosestCorner || m_sizeType == CSSValueID::FarthestCorner) { + if(m_circle) { + radiusX = radiusY = std::hypot(radiusX, radiusY); + } else { + // The ending shape keeps the aspect ratio of the matching side + // sized ellipse while passing through the corner. + radiusX *= std::numbers::sqrt2_v; + radiusY *= std::numbers::sqrt2_v; + } + } + + if(radiusX <= 0.f || radiusY <= 0.f) { + context.setColor(m_stops.back().color); + return; + } + + // The gradient ray runs from the center towards the right edge of the + // ending shape, so its length is the horizontal radius. + auto gradient = resolveGradient(radiusX, true); + if(gradient.degenerate) { + context.setColor(gradient.color); + return; + } + + // Cairo only draws circular gradients; the ellipse comes from scaling the + // pattern space, which keeps the shading vectorial. + auto transform = Transform::makeTranslate(center.x, center.y); + transform.scale(1.f, radiusY / radiusX); + + RadialGradientValues values; + values.r0 = radiusX * gradient.start; + values.r = radiusX * gradient.end; + context.setRadialGradient(values, gradient.stops, transform, gradient.method, 1.f); +} + +void GradientImage::applyConicGradient(GraphicsContext& context) const +{ + auto width = m_containerSize.w; + auto height = m_containerSize.h; + + Point center(m_position.x().calc(width), m_position.y().calc(height)); + + // The positions of a conic gradient are fractions of a turn, so its + // gradient line has no length of its own. + GradientStops stops; + buildColorStops(1.f, stops); + if(stops.back().first <= stops.front().first) { + context.setColor(stops.back().second); + return; + } + + ConicGradientValues values; + values.cx = center.x; + values.cy = center.y; + values.angle = m_angle; + + // The sweep has to reach every corner of the area it is painted into. + values.r = std::max(std::hypot(center.x, center.y), std::hypot(width - center.x, center.y)); + values.r = std::max(values.r, std::max(std::hypot(center.x, height - center.y), std::hypot(width - center.x, height - center.y))); + context.setConicGradient(values, stops, Transform(), m_repeating ? SpreadMethod::Repeat : SpreadMethod::Pad, 1.f); +} + +void GradientImage::apply(GraphicsContext& context) const +{ + switch(m_gradientType) { + case CSSGradientType::Linear: + applyLinearGradient(context); + break; + case CSSGradientType::Radial: + applyRadialGradient(context); + break; + case CSSGradientType::Conic: + applyConicGradient(context); + break; + } +} + +void GradientImage::draw(GraphicsContext& context, const Rect& dstRect, const Rect& srcRect) +{ + if(dstRect.isEmpty() || srcRect.isEmpty() || m_containerSize.isEmpty()) { + return; + } + + auto xScale = dstRect.w / srcRect.w; + auto yScale = dstRect.h / srcRect.h; + + auto xOffset = dstRect.x - (srcRect.x * xScale); + auto yOffset = dstRect.y - (srcRect.y * yScale); + + context.save(); + context.clipRect(dstRect); + context.translate(xOffset, yOffset); + context.scale(xScale, yScale); + apply(context); + context.fillRect(Rect(0, 0, m_containerSize.w, m_containerSize.h)); + context.restore(); +} + +void GradientImage::drawPattern(GraphicsContext& context, const Rect& destRect, const Size& size, const Size& scale, const Point& phase) +{ + assert(!destRect.isEmpty() && !size.isEmpty() && !scale.isEmpty()); + + cairo_matrix_t pattern_matrix; + cairo_matrix_init(&pattern_matrix, 1, 0, 0, 1, -phase.x, -phase.y); + + cairo_rectangle_t pattern_rectangle = {0, 0, size.w * scale.w, size.h * scale.h}; + auto pattern_surface = cairo_recording_surface_create(CAIRO_CONTENT_COLOR_ALPHA, &pattern_rectangle); + auto pattern_canvas = cairo_create(pattern_surface); + + auto pattern = cairo_pattern_create_for_surface(pattern_surface); + cairo_pattern_set_matrix(pattern, &pattern_matrix); + cairo_pattern_set_extend(pattern, CAIRO_EXTEND_REPEAT); + + GraphicsContext pattern_context(pattern_canvas); + pattern_context.scale(scale.w, scale.h); + apply(pattern_context); + pattern_context.fillRect(Rect(0, 0, size.w, size.h)); + + auto canvas = context.canvas(); + cairo_save(canvas); + cairo_set_fill_rule(canvas, CAIRO_FILL_RULE_WINDING); + cairo_rectangle(canvas, destRect.x, destRect.y, destRect.w, destRect.h); + cairo_set_source(canvas, pattern); + cairo_fill(canvas); + cairo_restore(canvas); + + cairo_pattern_destroy(pattern); + cairo_destroy(pattern_canvas); + cairo_surface_destroy(pattern_surface); +} + +void GradientImage::computeIntrinsicDimensions(float& intrinsicWidth, float& intrinsicHeight, double& intrinsicRatio) +{ + // A gradient has no intrinsic dimensions at all; it always takes the size + // of the area it is painted into. + intrinsicWidth = 0.f; + intrinsicHeight = 0.f; + intrinsicRatio = 0.0; +} + +RefPtr GradientImage::create(const BoxStyle& style, const CSSGradientValue& value) +{ + GradientColorStopList stops; + stops.reserve(value.stops().size()); + for(const auto& stop : value.stops()) { + GradientColorStop resolved; + resolved.hint = stop.isHint(); + if(!resolved.hint) + resolved.color = style.convertColor(*stop.color()); + if(stop.position() == nullptr) { + resolved.position = Length::Auto; + } else if(is(*stop.position())) { + resolved.position = Length(Length::Type::Percent, to(*stop.position()).valueInDegrees() / 3.6f); + } else { + resolved.position = style.convertLengthOrPercent(*stop.position()); + } + + stops.push_back(resolved); + } + + auto image = adoptPtr(new (style.heap()) GradientImage(value.gradientType(), value.repeating(), std::move(stops))); + if(const auto& angle = value.angle()) + image->m_angle = to(*angle).valueInDegrees(); + if(const auto& direction = value.direction()) { + const auto& pair = to(*direction); + image->m_directionX = pair.first()->id(); + image->m_directionY = pair.second()->id(); + } + + if(const auto& shape = value.shape()) + image->m_circle = shape->id() == CSSValueID::Circle; + if(const auto& size = value.size()) { + if(is(*size)) { + image->m_sizeType = size->id(); + } else if(is(*size)) { + const auto& pair = to(*size); + image->m_sizeType = CSSValueID::Unknown; + image->m_radiusX = style.convertLengthOrPercent(*pair.first()); + image->m_radiusY = style.convertLengthOrPercent(*pair.second()); + } else { + image->m_sizeType = CSSValueID::Unknown; + image->m_radiusX = style.convertLength(*size); + image->m_radiusY = image->m_radiusX; + } + } + + if(const auto& position = value.position()) + image->m_position = style.convertPositionCoordinate(*position); + return image; +} + +GradientImage::GradientImage(CSSGradientType gradientType, bool repeating, GradientColorStopList stops) + : m_gradientType(gradientType), m_repeating(repeating), m_stops(std::move(stops)) +{ + if(gradientType == CSSGradientType::Conic) { + m_angle = 0.f; + } +} + +} // namespace plutobook diff --git a/source/resource/gradientimage.h b/source/resource/gradientimage.h new file mode 100644 index 00000000..548c465d --- /dev/null +++ b/source/resource/gradientimage.h @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022-2026 Samuel Ugochukwu + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +#ifndef PLUTOBOOK_GRADIENTIMAGE_H +#define PLUTOBOOK_GRADIENTIMAGE_H + +#include "imageresource.h" +#include "graphicscontext.h" +#include "cssproperty.h" + +namespace plutobook { + +class BoxStyle; + +struct GradientColorStop { + Color color; + Length position; + bool hint{false}; +}; + +using GradientColorStopList = std::vector; + +class GradientImage final : public Image { +public: + static RefPtr create(const BoxStyle& style, const CSSGradientValue& value); + + void draw(GraphicsContext& context, const Rect& dstRect, const Rect& srcRect) final; + void drawPattern(GraphicsContext& context, const Rect& destRect, const Size& size, const Size& scale, const Point& phase) final; + void computeIntrinsicDimensions(float& intrinsicWidth, float& intrinsicHeight, double& intrinsicRatio) final; + + void setContainerSize(const Size& size) final { m_containerSize = size; } + Size intrinsicSize() const final { return Size(); } + Size size() const final { return m_containerSize; } + +private: + GradientImage(CSSGradientType gradientType, bool repeating, GradientColorStopList stops); + + struct ResolvedGradient { + GradientStops stops; + float start{0.f}; + float end{1.f}; + SpreadMethod method{SpreadMethod::Pad}; + bool degenerate{false}; + Color color; + }; + + void apply(GraphicsContext& context) const; + void applyLinearGradient(GraphicsContext& context) const; + void applyRadialGradient(GraphicsContext& context) const; + void applyConicGradient(GraphicsContext& context) const; + + // Resolves the color stop list onto a gradient line of the given length, + // following the CSS color stop fixup rules and expanding transition hints + // into sampled stops. The returned offsets are not clamped to [0, 1]. + void buildColorStops(float lineLength, GradientStops& stops) const; + + // Moves the stop offsets onto [0, 1] and reports, as fractions of the + // gradient line, where those two ends now sit. A radial or conic gradient + // sets |positiveOnly|, since its gradient ray starts at the center and + // nothing exists before it. + ResolvedGradient resolveGradient(float lineLength, bool positiveOnly) const; + + CSSGradientType m_gradientType; + bool m_repeating; + GradientColorStopList m_stops; + + float m_angle{180.f}; + CSSValueID m_directionX{CSSValueID::Unknown}; + CSSValueID m_directionY{CSSValueID::Unknown}; + + bool m_circle{false}; + CSSValueID m_sizeType{CSSValueID::FarthestCorner}; + Length m_radiusX; + Length m_radiusY; + LengthPoint m_position{Length(Length::Type::Percent, 50.f)}; + + Size m_containerSize; +}; + +} // namespace plutobook + +#endif // PLUTOBOOK_GRADIENTIMAGE_H