Add support for CSS gradients - #72
Draft
maitredede wants to merge 10 commits into
Draft
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements CSS gradients as background images, as described in #18.
Approach
A new
GradientImage : Image(source/resource/gradientimage.{h,cpp}) reuses theexisting gradient plumbing:
LinearGradientValues,RadialGradientValues,GradientStopsandSpreadMethodalready back the SVG gradients, so linear andradial need no new cairo code beyond an inner radius on the radial values.
CSSGradientValuedeliberately does not cache a resolved image the wayCSSImageValuedoes. A gradient is style dependent —currentColorandem/remlengths differ per element — so the image is built inBoxStyle::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-positionandbackground-repeatunchanged. Tiling mirrorsSVGImage::drawPattern, using arecording surface so a tiled gradient stays vectorial in the PDF.
Commits
CSSGradientValue,CSSGradientType,CSSGradientStop, andconsumeImage()accepting all six functions withstrict grammar validation.
GradientImage, the shared colorstop machinery, linear and repeating-linear rendering.
keywords, explicit radii,
at <position>; addsRadialGradientValues::r0(defaulted, so the SVG path is unaffected).
GraphicsContext::setConicGradient()built on
cairo_pattern_create_mesh, since cairo has no conic primitive.interpolateColormoves toColor.plus the public toggle described below.
CHANGELOG.mdandFEATURES.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:
with the usual C mirror (
plutobook_set_conic_gradient_rendering()), followingthe existing process-wide setters such as
plutobook_set_http_timeout().Meshis the default: a tensor patch mesh cut at every color stop and neverwider than 1/48 turn, which stays vectorial and zooms cleanly.
Rastersamplesthe 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 7with no images, against 37 kB with two embedded imagesfor 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/html2pngand compared against a headlessChrome 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
empositions, out-of-range and reversed stops,currentColor, the three repeating variants, and combinations withbackground-size/-position/-repeat), 30 radial cases (both shapes, all foursize keywords, explicit radii,
at <position>, degenerate zero radius), 20 coniccases, 23 invalid-syntax cases, and an SVG
radialGradientpage checking thatthe added inner radius causes no regression. All match.
PDF output was checked directly: repeating gradients come out as plain
/ShadingTypeentries with no embedded images, soCAIRO_EXTEND_REPEATisexpanded 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 ofwhite to blue.
Out of scope and known gaps
at <position>reuses the existing two-value position parser, so the four-valueform (
at left 10px top 20px) is rejected; the codebase has no four-valueposition parser.
image(),cross-fade(),element(), and css-images-4 interpolation spacessuch as
in oklch.list-style-image: <gradient>renders nothing where Chrome draws a smallsquare. 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:
contentconsumes images throughconsumeImage(), so a gradient could reach the content builder, whosedispatch chain ends in an unchecked
CSSUnaryFunctionValuecast.content: linear-gradient(red, blue)aborted in debug builds and readgarbage in release ones. It now builds a
GradientImage, which paintsnothing for want of a positioning area but is no longer misinterpreted.
range and the rasterized resolution were converted to
intbefore beingbounded, which is undefined for a nearly zero period or a very large radius.
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.