| /* | |
| * Copyright 2026 Rive | |
| */ | |
| #include "utils/svg_renderer.hpp" | |
| #include "rive/shapes/paint/color.hpp" | |
| #include <cmath> | |
| #include <cstdio> | |
| #include <iomanip> | |
| namespace rive | |
| { | |
| namespace | |
| { | |
| constexpr float kEpsilon = 1e-6f; | |
| const char* blendModeToCss(BlendMode mode) | |
| { | |
| switch (mode) | |
| { | |
| case BlendMode::srcOver: | |
| return nullptr; | |
| case BlendMode::additive: | |
| return "plus-lighter"; | |
| case BlendMode::screen: | |
| return "screen"; | |
| case BlendMode::overlay: | |
| return "overlay"; | |
| case BlendMode::darken: | |
| return "darken"; | |
| case BlendMode::lighten: | |
| return "lighten"; | |
| case BlendMode::colorDodge: | |
| return "color-dodge"; | |
| case BlendMode::colorBurn: | |
| return "color-burn"; | |
| case BlendMode::hardLight: | |
| return "hard-light"; | |
| case BlendMode::softLight: | |
| return "soft-light"; | |
| case BlendMode::difference: | |
| return "difference"; | |
| case BlendMode::exclusion: | |
| return "exclusion"; | |
| case BlendMode::multiply: | |
| return "multiply"; | |
| case BlendMode::hue: | |
| return "hue"; | |
| case BlendMode::saturation: | |
| return "saturation"; | |
| case BlendMode::color: | |
| return "color"; | |
| case BlendMode::luminosity: | |
| return "luminosity"; | |
| } | |
| RIVE_UNREACHABLE(); | |
| } | |
| // Inline " style=\"mix-blend-mode:..\"" suffix for a draw item. | |
| std::string blendStyle(BlendMode mode) | |
| { | |
| const char* css = blendModeToCss(mode); | |
| if (css == nullptr) | |
| { | |
| return {}; | |
| } | |
| return std::string(" style=\"mix-blend-mode:") + css + "\""; | |
| } | |
| } // namespace | |
| void SVGRenderer::save() | |
| { | |
| StackEntry next; | |
| next.matrix = m_stack.back().matrix; | |
| next.opacity = m_stack.back().opacity; | |
| m_stack.emplace_back(std::move(next)); | |
| } | |
| void SVGRenderer::restore() | |
| { | |
| // The bottom-most entry is the implicit root and must never be popped. | |
| if (m_stack.size() <= 1) | |
| { | |
| return; | |
| } | |
| StackEntry entry = std::move(m_stack.back()); | |
| m_stack.pop_back(); | |
| // Close any clip-path <g> wrappers we opened directly into this entry. | |
| for (int i = 0; i < entry.openClipGroups; ++i) | |
| { | |
| Item closeItem; | |
| closeItem.body = "</g>\n"; | |
| closeItem.isDraw = false; | |
| entry.items.emplace_back(std::move(closeItem)); | |
| } | |
| // Flush this entry's items into the parent's items as a single | |
| // pass-through chunk so the parent never re-wraps our content. | |
| std::ostringstream chunk; | |
| writeEntry(chunk, entry); | |
| auto& parent = m_stack.back(); | |
| Item passthrough; | |
| passthrough.body = chunk.str(); | |
| passthrough.isDraw = false; | |
| // When the chunk is exactly one top-level clip group (the clip opened | |
| // first and its close is last), record the clip id so writeEntry can | |
| // merge adjacent chunks sharing the same clip. | |
| if (entry.openClipGroups >= 1 && !entry.items.empty() && | |
| entry.items.front().clipOpenId >= 0 && !wantsBlendWrap(entry)) | |
| { | |
| passthrough.singleClipId = entry.items.front().clipOpenId; | |
| passthrough.clipOpenLen = entry.items.front().body.size(); | |
| } | |
| parent.items.emplace_back(std::move(passthrough)); | |
| } | |
| void SVGRenderer::transform(const Mat2D& mat) | |
| { | |
| auto& top = m_stack.back(); | |
| top.matrix = top.matrix * mat; | |
| } | |
| void SVGRenderer::modulateOpacity(float opacity) | |
| { | |
| auto& top = m_stack.back(); | |
| top.opacity *= opacity; | |
| } | |
| void SVGRenderer::writeTransform(std::ostream& out, const Mat2D& m) | |
| { | |
| bool isIdentityLinear = | |
| std::abs(m[0] - 1) < kEpsilon && std::abs(m[1]) < kEpsilon && | |
| std::abs(m[2]) < kEpsilon && std::abs(m[3] - 1) < kEpsilon; | |
| bool zeroT = std::abs(m[4]) < kEpsilon && std::abs(m[5]) < kEpsilon; | |
| if (isIdentityLinear && zeroT) | |
| { | |
| return; | |
| } | |
| if (isIdentityLinear) | |
| { | |
| out << " transform=\"translate(" << m[4] << ' ' << m[5] << ")\""; | |
| return; | |
| } | |
| out << " transform=\"matrix(" << m[0] << ' ' << m[1] << ' ' << m[2] << ' ' | |
| << m[3] << ' ' << m[4] << ' ' << m[5] << ")\""; | |
| } | |
| void SVGRenderer::writeColor(std::ostream& out, | |
| unsigned r, | |
| unsigned g, | |
| unsigned b) | |
| { | |
| auto prevFlags = out.flags(); | |
| auto prevFill = out.fill(); | |
| out << '#' << std::hex << std::setfill('0') << std::setw(2) << r | |
| << std::setw(2) << g << std::setw(2) << b; | |
| out.flags(prevFlags); | |
| out.fill(prevFill); | |
| } | |
| void SVGRenderer::emitPaintAttributes(std::ostream& out, | |
| SVGRenderPaint* paint, | |
| SVGRenderPath* path, | |
| float extraOpacity) | |
| { | |
| ColorInt c = paint->getColor(); | |
| unsigned r = colorRed(c); | |
| unsigned g = colorGreen(c); | |
| unsigned b = colorBlue(c); | |
| float a = (colorAlpha(c) / 255.0f) * extraOpacity; | |
| auto* shader = paint->getSvgShader(); | |
| if (paint->isStroke()) | |
| { | |
| out << " fill=\"none\""; | |
| if (shader) | |
| { | |
| std::string gradId = "grad" + std::to_string(m_gradientIdCounter++); | |
| shader->emitDefs(m_defs, gradId); | |
| out << " stroke=\"url(#" << gradId << ")\""; | |
| } | |
| else | |
| { | |
| out << " stroke=\""; | |
| writeColor(out, r, g, b); | |
| out << "\""; | |
| } | |
| if (a < 1.0f - kEpsilon) | |
| { | |
| out << " stroke-opacity=\"" << a << "\""; | |
| } | |
| out << " stroke-width=\"" << paint->getThickness() << "\""; | |
| switch (paint->getJoin()) | |
| { | |
| case StrokeJoin::miter: | |
| break; | |
| case StrokeJoin::round: | |
| out << " stroke-linejoin=\"round\""; | |
| break; | |
| case StrokeJoin::bevel: | |
| out << " stroke-linejoin=\"bevel\""; | |
| break; | |
| } | |
| switch (paint->getCap()) | |
| { | |
| case StrokeCap::butt: | |
| break; | |
| case StrokeCap::round: | |
| out << " stroke-linecap=\"round\""; | |
| break; | |
| case StrokeCap::square: | |
| out << " stroke-linecap=\"square\""; | |
| break; | |
| } | |
| } | |
| else | |
| { | |
| if (shader) | |
| { | |
| std::string gradId = "grad" + std::to_string(m_gradientIdCounter++); | |
| shader->emitDefs(m_defs, gradId); | |
| out << " fill=\"url(#" << gradId << ")\""; | |
| } | |
| else if (r != 0 || g != 0 || b != 0) | |
| { | |
| // Skip the attribute entirely for the SVG default (black). | |
| out << " fill=\""; | |
| writeColor(out, r, g, b); | |
| out << "\""; | |
| } | |
| if (a < 1.0f - kEpsilon) | |
| { | |
| out << " fill-opacity=\"" << a << "\""; | |
| } | |
| if (path->getFillRule() == FillRule::evenOdd) | |
| { | |
| out << " fill-rule=\"evenodd\""; | |
| } | |
| } | |
| } | |
| void SVGRenderer::drawPath(RenderPath* renderPath, RenderPaint* renderPaint) | |
| { | |
| auto* path = static_cast<SVGRenderPath*>(renderPath); | |
| auto* paint = static_cast<SVGRenderPaint*>(renderPaint); | |
| std::string d = path->toSvgD(m_floatPrecision); | |
| if (d.empty()) | |
| { | |
| return; | |
| } | |
| auto& top = m_stack.back(); | |
| // Build the element body without any inline blend style. Blend is applied | |
| // at flush time so that multiple draws inside a single save block sharing | |
| // a non-srcOver blend can be wrapped in a <g style="..."> instead. | |
| std::ostringstream body; | |
| body << std::setprecision(m_floatPrecision); | |
| body << "<path"; | |
| writeTransform(body, top.matrix); | |
| emitPaintAttributes(body, paint, path, top.opacity); | |
| body << " d=\"" << d << "\"/>\n"; | |
| Item item; | |
| item.body = body.str(); | |
| item.blend = paint->getBlendMode(); | |
| item.isDraw = true; | |
| top.items.emplace_back(std::move(item)); | |
| } | |
| void SVGRenderer::clipPath(RenderPath* renderPath) | |
| { | |
| auto* path = static_cast<SVGRenderPath*>(renderPath); | |
| std::string d = path->toSvgD(m_floatPrecision); | |
| if (d.empty()) | |
| { | |
| return; | |
| } | |
| auto& top = m_stack.back(); | |
| // The clip geometry is in the coordinate space current at clip time, so | |
| // bake the CTM onto the def's <path>. The wrapping <g> must stay | |
| // transform-free: children write their own absolute matrix on each | |
| // <path>, and a transform here would compose with (double-apply) it. | |
| std::ostringstream defPath; | |
| defPath << std::setprecision(m_floatPrecision); | |
| defPath << "<path"; | |
| writeTransform(defPath, top.matrix); | |
| defPath << " d=\"" << d << "\""; | |
| if (path->getFillRule() == FillRule::evenOdd) | |
| { | |
| defPath << " clip-rule=\"evenodd\""; | |
| } | |
| defPath << "/>"; | |
| // Identical geometry reuses the existing def. | |
| auto found = m_clipDefIds.find(defPath.str()); | |
| int clipId; | |
| if (found != m_clipDefIds.end()) | |
| { | |
| clipId = found->second; | |
| } | |
| else | |
| { | |
| clipId = m_clipIdCounter++; | |
| m_clipDefIds.emplace(defPath.str(), clipId); | |
| m_defs << "<clipPath id=\"clip" << clipId << "\">" << defPath.str() | |
| << "</clipPath>\n"; | |
| } | |
| Item item; | |
| item.body = "<g clip-path=\"url(#clip" + std::to_string(clipId) + ")\">\n"; | |
| item.clipOpenId = clipId; | |
| top.items.emplace_back(std::move(item)); | |
| top.openClipGroups++; | |
| } | |
| void SVGRenderer::drawImage(const RenderImage* image, | |
| ImageSampler, | |
| BlendMode blendMode, | |
| float opacity) | |
| { | |
| auto* svgImage = static_cast<const SVGRenderImage*>(image); | |
| auto& top = m_stack.back(); | |
| float a = top.opacity * opacity; | |
| std::ostringstream body; | |
| body << std::setprecision(m_floatPrecision); | |
| body << "<image width=\"" << image->width() << "\" height=\"" | |
| << image->height() << "\" href=\"" << svgImage->toDataURI() << "\""; | |
| writeTransform(body, top.matrix); | |
| if (a < 1.0f - kEpsilon) | |
| { | |
| body << " opacity=\"" << a << "\""; | |
| } | |
| body << "/>\n"; | |
| Item item; | |
| item.body = body.str(); | |
| item.blend = blendMode; | |
| item.isDraw = true; | |
| top.items.emplace_back(std::move(item)); | |
| } | |
| void SVGRenderer::drawImageMesh(const RenderImage*, | |
| ImageSampler, | |
| rcp<RenderBuffer>, | |
| rcp<RenderBuffer>, | |
| rcp<RenderBuffer>, | |
| uint32_t, | |
| uint32_t, | |
| BlendMode, | |
| float) | |
| { | |
| static bool warned = false; | |
| if (!warned) | |
| { | |
| fprintf(stderr, | |
| "SVGRenderer: drawImageMesh is not supported in SVG output " | |
| "and will be skipped.\n"); | |
| warned = true; | |
| } | |
| } | |
| bool SVGRenderer::wantsBlendWrap(const StackEntry& entry) | |
| { | |
| // All draw items must share a single non-srcOver blend mode. | |
| BlendMode firstBlend = BlendMode::srcOver; | |
| int drawCount = 0; | |
| for (const auto& it : entry.items) | |
| { | |
| if (!it.isDraw) | |
| { | |
| continue; | |
| } | |
| if (drawCount == 0) | |
| { | |
| firstBlend = it.blend; | |
| } | |
| else if (it.blend != firstBlend) | |
| { | |
| return false; | |
| } | |
| drawCount++; | |
| } | |
| return drawCount > 1 && firstBlend != BlendMode::srcOver; | |
| } | |
| void SVGRenderer::writeEntry(std::ostream& dst, const StackEntry& entry) | |
| { | |
| if (entry.items.empty()) | |
| { | |
| return; | |
| } | |
| // If all draw items share a single non-srcOver blend mode, wrap them in | |
| // a group with that blend (children get no inline style). Otherwise, | |
| // emit each item with its own inline blend style. | |
| bool wrap = wantsBlendWrap(entry); | |
| if (wrap) | |
| { | |
| BlendMode firstBlend = BlendMode::srcOver; | |
| for (const auto& it : entry.items) | |
| { | |
| if (it.isDraw) | |
| { | |
| firstBlend = it.blend; | |
| break; | |
| } | |
| } | |
| // wantsBlendWrap() guarantees firstBlend is not srcOver. | |
| dst << "<g" << blendStyle(firstBlend) << ">\n"; | |
| } | |
| constexpr size_t kClipCloseLen = sizeof("</g>\n") - 1; | |
| const auto& items = entry.items; | |
| for (size_t i = 0; i < items.size(); i++) | |
| { | |
| const Item& it = items[i]; | |
| if (!it.isDraw || wrap || it.blend == BlendMode::srcOver) | |
| { | |
| // Pass-through (clip groups, restored chunks) or wrapped draws or | |
| // a draw with no blend at all: emit body verbatim — except that | |
| // adjacent single-clip chunks sharing a clip merge into one | |
| // group, by dropping this chunk's close and/or open tag. | |
| bool mergePrev = it.singleClipId >= 0 && i > 0 && | |
| items[i - 1].singleClipId == it.singleClipId; | |
| bool mergeNext = it.singleClipId >= 0 && i + 1 < items.size() && | |
| items[i + 1].singleClipId == it.singleClipId; | |
| size_t begin = mergePrev ? it.clipOpenLen : 0; | |
| size_t end = it.body.size() - (mergeNext ? kClipCloseLen : 0); | |
| dst.write(it.body.data() + begin, | |
| static_cast<std::streamsize>(end - begin)); | |
| } | |
| else | |
| { | |
| // Single-draw blend (or mixed-blend save block): inline the style | |
| // by inserting it just before the self-closing "/>". | |
| std::string body = it.body; | |
| std::string style = blendStyle(it.blend); | |
| auto close = body.rfind("/>"); | |
| if (close != std::string::npos && !style.empty()) | |
| { | |
| body.insert(close, style); | |
| } | |
| dst << body; | |
| } | |
| } | |
| if (wrap) | |
| { | |
| dst << "</g>\n"; | |
| } | |
| } | |
| std::string SVGRenderer::finalize(int width, int height) const | |
| { | |
| std::ostringstream svg; | |
| svg << "<svg xmlns=\"http://www.w3.org/2000/svg\" " | |
| << "xmlns:xlink=\"http://www.w3.org/1999/xlink\" " | |
| << "viewBox=\"0 0 " << width << " " << height << "\" " | |
| << "width=\"" << width << "\" height=\"" << height << "\">\n"; | |
| std::string defs = m_defs.str(); | |
| if (!defs.empty()) | |
| { | |
| svg << "<defs>\n" << defs << "</defs>\n"; | |
| } | |
| // The root entry is never restored. Flush it directly, applying the same | |
| // blend-grouping rules so that root-level draws behave consistently. | |
| if (!m_stack.empty()) | |
| { | |
| writeEntry(svg, m_stack.front()); | |
| } | |
| svg << "</svg>\n"; | |
| return svg.str(); | |
| } | |
| } // namespace rive |