Skip to content

Add support for CSS gradients - #72

Draft
maitredede wants to merge 10 commits into
plutoprint:mainfrom
maitredede:feature/css-gradients
Draft

Add support for CSS gradients#72
maitredede wants to merge 10 commits into
plutoprint:mainfrom
maitredede:feature/css-gradients

Conversation

@maitredede

@maitredede maitredede commented Aug 12, 2026

Copy link
Copy Markdown

Implements CSS gradients as background images, as described in #18.

Approach

A new GradientImage : Image (source/resource/gradientimage.{h,cpp}) reuses the
existing gradient plumbing: LinearGradientValues, RadialGradientValues,
GradientStops and SpreadMethod already back the SVG gradients, so linear and
radial need no new cairo code beyond an inner radius on the radial values.

CSSGradientValue deliberately does not cache a resolved image the way
CSSImageValue does. A gradient is style dependent — currentColor and
em/rem lengths differ per element — so the image is built in
BoxStyle::convertImage, which has the computed style at hand.

Gradients report no intrinsic dimensions, so they size to the positioning area
and integrate with background-size, background-position and
background-repeat unchanged. Tiling mirrors SVGImage::drawPattern, using a
recording surface so a tiled gradient stays vectorial in the PDF.

Commits

  1. Parse CSS gradient functionsCSSGradientValue, CSSGradientType,
    CSSGradientStop, and consumeImage() accepting all six functions with
    strict grammar validation.
  2. Add support for CSS linear gradientsGradientImage, the shared color
    stop machinery, linear and repeating-linear rendering.
  3. Add support for CSS radial gradients — both shapes, the four size
    keywords, explicit radii, at <position>; adds RadialGradientValues::r0
    (defaulted, so the SVG path is unaffected).
  4. Add support for CSS conic gradientsGraphicsContext::setConicGradient()
    built on cairo_pattern_create_mesh, since cairo has no conic primitive.
    interpolateColor moves to Color.
  5. Add a rasterized conic gradient rendering mode — an alternative sampler
    plus the public toggle described below.
  6. Accept a gradient with a single color stop — per css-images-4.
  7. Document CSS gradient supportCHANGELOG.md and FEATURES.md.

Commits 4 and 5 are kept separate so the rasterized path can be dropped or
cherry-picked independently of the mesh implementation.

Conic gradients and the new setting

Neither cairo nor PDF has a conic shading, so the sweep must be approximated.
Two implementations are provided, selected at runtime:

enum class ConicGradientRendering { Mesh, Raster };
void setConicGradientRendering(ConicGradientRendering rendering);
ConicGradientRendering conicGradientRendering();

with the usual C mirror (plutobook_set_conic_gradient_rendering()), following
the existing process-wide setters such as plutobook_set_http_timeout().

Mesh is the default: a tensor patch mesh cut at every color stop and never
wider than 1/48 turn, which stays vectorial and zooms cleanly. Raster samples
the sweep into a bitmap at the current device scale (capped at 2048²), which is
exact in angle but resolution bound. On a two-conic page the mesh output is
21 kB of /ShadingType 7 with no images, against 37 kB with two embedded images
for the raster path; the two are visually indistinguishable at 96 dpi.

If the raster path is unwanted, dropping commit 5 leaves a working mesh-only
implementation with no public API addition.

Validation

Every case was rendered with tools/html2png and compared against a headless
Chrome screenshot of the same page: 30 linear cases (side and corner keywords,
angles in deg/turn/negative, explicit, implicit and two-position stops,
transition hints, px and em positions, out-of-range and reversed stops,
currentColor, the three repeating variants, and combinations with
background-size/-position/-repeat), 30 radial cases (both shapes, all four
size keywords, explicit radii, at <position>, degenerate zero radius), 20 conic
cases, 23 invalid-syntax cases, and an SVG radialGradient page checking that
the added inner radius causes no regression. All match.

PDF output was checked directly: repeating gradients come out as plain
/ShadingType entries with no embedded images, so CAIRO_EXTEND_REPEAT is
expanded into the PDF function domain and no stop-materialisation workaround is
needed.

Two fidelity notes. Transition hints are flattened into 12 sampled stops, since
cairo has no midpoint concept. Adjacent stops of differing opacity are sampled
the same way, because cairo interpolates stops without premultiplying alpha
while CSS requires premultiplied interpolation — without this,
linear-gradient(rgba(255,0,0,0), blue) ramps through pink instead of
white to blue.

Out of scope and known gaps

  • Multiple background layers (Add support for multiple backgrounds #17) — there is still exactly one background image.
  • at <position> reuses the existing two-value position parser, so the four-value
    form (at left 10px top 20px) is rejected; the codebase has no four-value
    position parser.
  • image(), cross-fade(), element(), and css-images-4 interpolation spaces
    such as in oklch.
  • list-style-image: <gradient> renders nothing where Chrome draws a small
    square. A gradient must report no intrinsic dimensions in order to size to the
    positioning area, and a marker box has none, so it collapses to 0×0. It does
    not crash. Fixing it needs default sizing for replaced content, which felt out
    of scope here — happy to address it if you would rather it were handled.

Review follow-ups

Three fixes found while reviewing the branch:

  • Handle gradients in generated contentcontent consumes images through
    consumeImage(), so a gradient could reach the content builder, whose
    dispatch chain ends in an unchecked CSSUnaryFunctionValue cast.
    content: linear-gradient(red, blue) aborted in debug builds and read
    garbage in release ones. It now builds a GradientImage, which paints
    nothing for want of a positioning area but is no longer misinterpreted.
  • Clamp conic gradient bounds before narrowing them — the repeating period
    range and the rasterized resolution were converted to int before being
    bounded, which is undefined for a nearly zero period or a very large radius.
  • Memoize gradient images on the style — a gradient image was rebuilt on
    every call, once per box per page, and the document heap only reclaims at
    teardown, so a long document accumulated identical images. They are now
    cached on the style that produced them.

maitredede and others added 10 commits August 12, 2026 16:13
Add a CSSGradientValue that captures the parsed form of the six CSS
gradient image functions: linear-gradient(), radial-gradient(),
conic-gradient() and their repeating-* variants.

The value deliberately keeps its parts unresolved. Unlike a url(), a
gradient is not style independent: currentColor and font relative
lengths inside it only have a meaning once an element's computed style
is known, so resolution has to happen later, per style.

consumeImage() now accepts these functions in addition to a url token.
The grammar is validated strictly: a linear gradient takes either an
angle or a "to <side-or-corner>", a radial gradient rejects a circle
sized with a percentage or with two radii, a color hint may not start
or end the stop list, and at least two color stops are required.

Rendering follows in a later change; for now a gradient resolves to no
image at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Introduce GradientImage, an Image that paints a CSS gradient rather
than decoded image data. It is built in BoxStyle::convertImage, where
the computed style is available, so that currentColor and font relative
lengths inside the gradient resolve against the element using it. The
image is deliberately not cached in the CSS value, which is shared
between every element matching the rule.

A gradient has no intrinsic dimensions, so it always takes the size of
the area it is painted into; setContainerSize drives the geometry and
the whole ramp is emitted as a cairo shading, which keeps it vectorial
in the PDF output.

This change renders linear-gradient() and repeating-linear-gradient(),
with the color stop machinery shared by the other gradient types:
implicit positions spread evenly, positions are forced to be non
decreasing, and transition hints are flattened into sampled stops since
cairo has no notion of a color midpoint. Stops of differing opacity are
sampled too, because cairo blends stops without premultiplying alpha
while CSS requires premultiplied interpolation.

Since cairo clamps stop offsets to [0, 1], the gradient geometry is
moved onto the span the stops actually cover instead of clamping them,
which also gives repeating gradients for free through EXTEND_REPEAT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Render radial-gradient() and repeating-radial-gradient(), with both
ending shapes, the four size keywords, explicit radii and an optional
center position.

Cairo only draws circular gradients, so an elliptical ending shape is
realised by scaling the pattern space rather than by rasterising, which
keeps the shading vectorial in the PDF output. A radial gradient ray
starts at the center, so stops that fall before it are folded onto the
origin, and a repeating ramp is slid by whole periods to reach it; the
inner radius of the cairo shading then carries the offset of the first
stop, which is what makes EXTEND_REPEAT repeat with the right period.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Render conic-gradient() and repeating-conic-gradient(), with an
optional starting angle and center position.

Cairo has no conic gradient primitive, so GraphicsContext approximates
the sweep with a mesh pattern made of circular sectors, each one a
Coons patch whose two radial edges carry a single color. Sectors are
cut at every color stop and are never wider than a 48th of a turn, so
the piecewise linear approximation of the ramp stays below the visible
threshold. The result is emitted by the PDF backend as a shading rather
than as a bitmap, so it remains vectorial and resolution independent.

The color blending helper moves to Color, since the mesh has to sample
the ramp the same way the shading based gradients do.

A gradient whose stops all coincide now paints the color of its last
stop, matching both browsers and the existing SVG gradient path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mesh approximation of a conic sweep is a good default, but it is
still an approximation, and a caller who cares more about an exact
sweep than about a resolution independent PDF has no way to say so.

Add a second implementation that samples the sweep into a bitmap, and
a runtime setting to choose between the two, defaulting to the mesh.
The setting is exposed from both the C++ and the C API, mirroring the
other global settings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CSS Images Level 4 makes the rest of the color stop list optional, and
browsers accept it, so linear-gradient(red) is a valid way of spelling
a solid color. The existing degenerate handling already paints the
color of the last stop, so nothing else has to change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`consumeImage()` accepts gradient functions, and the `content` property
consumes images through it, so a gradient could reach the content builder.
Its dispatch chain ends with an unchecked `CSSUnaryFunctionValue` cast,
which aborts in debug builds and reads garbage in release ones.

Build a `GradientImage` for that case instead. A gradient has no
intrinsic dimensions and generated content has no positioning area to
size it against, so nothing is painted, but the value is now handled
rather than misinterpreted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both the repeating period range and the rasterized sweep resolution were
converted to `int` before being bounded. A repeating sweep whose stops
nearly coincide, or a very large radius, produces a float far outside the
`int` range, and that conversion is undefined.

Bound both in the float domain instead, so a non-finite value settles on
a limit rather than being converted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A gradient image depends on the computed style, so unlike a fetched one
it cannot be cached on the shared CSS value. It was therefore rebuilt on
every call, which painting makes once per box per page, and the document
heap only reclaims at teardown, so a long document kept accumulating
images that were all identical.

Cache them on the style that produced them, keyed by the gradient value,
which is the narrowest scope where `currentColor` and font relative
lengths are already fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant