| // Unit tests for the SVG renderer (SVGRenderer). |
| // |
| // These tests exercise SVGRenderer directly via SVGFactory, asserting on the |
| // exact structure of the SVG produced by finalize(). They verify the |
| // flat-output / lazy-group behavior described in |
| // `flatten_svg_extractor_output` plan. |
| |
| #include "rive/file.hpp" |
| #include "rive/math/mat2d.hpp" |
| #include "rive/renderer.hpp" |
| #include "rive/shapes/paint/color.hpp" |
| #include "utils/svg_factory.hpp" |
| #include "utils/svg_renderer.hpp" |
| #include "rive_file_reader.hpp" |
| #include <catch.hpp> |
| #include <string> |
| |
| using rive::SVGFactory; |
| using rive::SVGRenderer; |
| using rive::SVGRenderPaint; |
| |
| namespace |
| { |
| |
| // Returns the substring of `out` between the closing of the <svg> opening tag |
| // (or the closing </defs>, whichever is later) and the closing </svg>. |
| std::string body(const std::string& out) |
| { |
| auto svgClose = out.rfind("</svg>"); |
| REQUIRE(svgClose != std::string::npos); |
| auto end = svgClose; |
| |
| auto defsClose = out.find("</defs>"); |
| if (defsClose != std::string::npos) |
| { |
| return out.substr(defsClose + std::string("</defs>").size(), |
| end - (defsClose + std::string("</defs>").size())); |
| } |
| auto svgOpen = out.find('>'); |
| REQUIRE(svgOpen != std::string::npos); |
| return out.substr(svgOpen + 1, end - (svgOpen + 1)); |
| } |
| |
| int countOccurrences(const std::string& s, const std::string& needle) |
| { |
| int n = 0; |
| size_t pos = 0; |
| while ((pos = s.find(needle, pos)) != std::string::npos) |
| { |
| n++; |
| pos += needle.size(); |
| } |
| return n; |
| } |
| |
| // Build a simple solid-color fill paint. |
| rive::rcp<rive::RenderPaint> makeFillPaint( |
| SVGFactory& f, |
| rive::ColorInt color, |
| rive::BlendMode blend = rive::BlendMode::srcOver) |
| { |
| auto p = f.makeRenderPaint(); |
| static_cast<SVGRenderPaint*>(p.get())->style(rive::RenderPaintStyle::fill); |
| static_cast<SVGRenderPaint*>(p.get())->color(color); |
| static_cast<SVGRenderPaint*>(p.get())->blendMode(blend); |
| return p; |
| } |
| |
| rive::rcp<rive::RenderPaint> makeStrokePaint( |
| SVGFactory& f, |
| rive::ColorInt color, |
| float thickness, |
| rive::StrokeJoin join = rive::StrokeJoin::miter, |
| rive::StrokeCap cap = rive::StrokeCap::butt) |
| { |
| auto p = f.makeRenderPaint(); |
| auto* sp = static_cast<SVGRenderPaint*>(p.get()); |
| sp->style(rive::RenderPaintStyle::stroke); |
| sp->color(color); |
| sp->thickness(thickness); |
| sp->join(join); |
| sp->cap(cap); |
| return p; |
| } |
| |
| // A trivial 1-point square path used by most tests. |
| rive::rcp<rive::RenderPath> makeSquarePath(SVGFactory& f) |
| { |
| rive::RawPath rp; |
| rp.moveTo(0, 0); |
| rp.line({1, 0}); |
| rp.line({1, 1}); |
| rp.line({0, 1}); |
| rp.close(); |
| return f.makeRenderPath(rp, rive::FillRule::nonZero); |
| } |
| |
| } // namespace |
| |
| TEST_CASE("SVGRenderer: empty save/restore produces no <g> wrappers", |
| "[svg_renderer]") |
| { |
| SVGRenderer r; |
| r.save(); |
| r.restore(); |
| auto out = r.finalize(10, 10); |
| auto b = body(out); |
| REQUIRE(countOccurrences(b, "<g") == 0); |
| REQUIRE(countOccurrences(b, "</g>") == 0); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: pure translation uses translate(x y) and inlines on path", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| r.save(); |
| r.transform(rive::Mat2D::fromTranslate(10, 20)); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto out = r.finalize(64, 64); |
| auto b = body(out); |
| |
| REQUIRE(b.find("transform=\"translate(10 20)\"") != std::string::npos); |
| REQUIRE(b.find("matrix(") == std::string::npos); |
| // No wrapping <g> at all - the transform is on the path itself. |
| REQUIRE(countOccurrences(b, "<g") == 0); |
| REQUIRE(countOccurrences(b, "<path") == 1); |
| } |
| |
| TEST_CASE("SVGRenderer: identity transform is omitted from the path", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| r.save(); |
| // Identity transform applied explicitly should still produce no |
| // transform attribute. |
| r.transform(rive::Mat2D()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto b = body(r.finalize(64, 64)); |
| REQUIRE(b.find("transform=") == std::string::npos); |
| REQUIRE(countOccurrences(b, "<path") == 1); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: nested transforms multiply into a single matrix on the path", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| rive::Mat2D A = rive::Mat2D::fromTranslate(10, 20); |
| rive::Mat2D B = rive::Mat2D::fromScale(2.0f, 3.0f); |
| rive::Mat2D expected = A * B; |
| |
| r.save(); |
| r.transform(A); |
| r.save(); |
| r.transform(B); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| r.restore(); |
| |
| auto b = body(r.finalize(64, 64)); |
| REQUIRE(countOccurrences(b, "<path") == 1); |
| |
| // matrix(2 0 0 3 10 20) |
| std::ostringstream want; |
| want << "transform=\"matrix(" << expected[0] << ' ' << expected[1] << ' ' |
| << expected[2] << ' ' << expected[3] << ' ' << expected[4] << ' ' |
| << expected[5] << ")\""; |
| REQUIRE(b.find(want.str()) != std::string::npos); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: default-black fill is omitted; non-black uses #rrggbb hex", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| |
| SECTION("Default black, full opacity, no fill attribute") |
| { |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFF000000); |
| r.drawPath(path.get(), paint.get()); |
| auto b = body(r.finalize(8, 8)); |
| REQUIRE(b.find("fill=") == std::string::npos); |
| } |
| |
| SECTION("Non-black fill emits hex") |
| { |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFF12ab34); |
| r.drawPath(path.get(), paint.get()); |
| auto b = body(r.finalize(8, 8)); |
| REQUIRE(b.find("fill=\"#12ab34\"") != std::string::npos); |
| REQUIRE(b.find("rgb(") == std::string::npos); |
| } |
| } |
| |
| TEST_CASE("SVGRenderer: modulateOpacity multiplies into fill-opacity", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| |
| SECTION("opacity=1.0 produces no fill-opacity attribute") |
| { |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| r.modulateOpacity(1.0f); |
| r.drawPath(path.get(), paint.get()); |
| auto b = body(r.finalize(8, 8)); |
| REQUIRE(b.find("fill-opacity") == std::string::npos); |
| } |
| |
| SECTION("0.5 modulation * paint alpha 0.5 -> fill-opacity 0.25") |
| { |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| // alpha = 128 / 255 ~= 0.50196 |
| auto paint = makeFillPaint(f, 0x80FF0000); |
| r.save(); |
| r.modulateOpacity(0.5f); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| auto b = body(r.finalize(8, 8)); |
| // We just verify the attribute is present and starts with 0.25 |
| auto p = b.find("fill-opacity=\""); |
| REQUIRE(p != std::string::npos); |
| std::string val = |
| b.substr(p + std::string("fill-opacity=\"").size(), 4); |
| REQUIRE(val.substr(0, 3) == "0.2"); |
| } |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: stroke join/cap defaults are omitted, non-defaults emit", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| |
| SECTION("Default miter + butt: no linejoin/linecap attributes") |
| { |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeStrokePaint(f, 0xFF000000, 2.0f); |
| r.drawPath(path.get(), paint.get()); |
| auto b = body(r.finalize(8, 8)); |
| REQUIRE(b.find("stroke-linejoin") == std::string::npos); |
| REQUIRE(b.find("stroke-linecap") == std::string::npos); |
| } |
| |
| SECTION("Round join + round cap: both attributes present") |
| { |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeStrokePaint(f, |
| 0xFF000000, |
| 2.0f, |
| rive::StrokeJoin::round, |
| rive::StrokeCap::round); |
| r.drawPath(path.get(), paint.get()); |
| auto b = body(r.finalize(8, 8)); |
| REQUIRE(b.find("stroke-linejoin=\"round\"") != std::string::npos); |
| REQUIRE(b.find("stroke-linecap=\"round\"") != std::string::npos); |
| } |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: clipPath emits exactly one <g clip-path> wrapping its children", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| |
| auto clip = makeSquarePath(f); |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| r.save(); |
| r.clipPath(clip.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto out = r.finalize(64, 64); |
| auto b = body(out); |
| |
| REQUIRE(countOccurrences(b, "<g clip-path=\"url(#clip0)\"") == 1); |
| REQUIRE(countOccurrences(b, "</g>") == 1); |
| REQUIRE(countOccurrences(b, "<path") == 2); |
| REQUIRE(out.find("<defs>") != std::string::npos); |
| REQUIRE(out.find("<clipPath id=\"clip0\">") != std::string::npos); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: clip under a non-identity CTM does not double-transform " |
| "children", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| |
| auto clip = makeSquarePath(f); |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| // Mirrors a real recording: the extractor aligns (scale 2, offset 10,10), |
| // the artboard saves and clips in artboard space, then a shape saves, |
| // applies its world transform and draws its local path. |
| r.save(); |
| r.transform(rive::Mat2D(2, 0, 0, 2, 10, 10)); // align |
| r.save(); |
| r.clipPath(clip.get()); // artboard-space clip, CTM = align |
| r.save(); |
| r.transform(rive::Mat2D(1, 0, 0, 1, 5, 7)); // shape world transform |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| r.restore(); |
| r.restore(); |
| |
| auto out = r.finalize(64, 64); |
| auto b = body(out); |
| |
| // The CTM at clip time is baked into the def's clip geometry... |
| REQUIRE(out.find("<clipPath id=\"clip0\">" |
| "<path transform=\"matrix(2 0 0 2 10 10)\"") != |
| std::string::npos); |
| // ...and the wrapping <g> carries no transform, so the child's absolute |
| // matrix (align * world) is applied exactly once. |
| REQUIRE(b.find("<g clip-path=\"url(#clip0)\">") != std::string::npos); |
| REQUIRE(countOccurrences(b, "transform=\"matrix(2 0 0 2 20 24)\"") == 1); |
| } |
| |
| TEST_CASE("SVGRenderer: identical clip geometry shares one def", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| |
| auto clip = makeSquarePath(f); |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| // Two clip scopes with the same geometry, separated by an unclipped |
| // draw. Scopes must stay separate (z-order), but share one def. |
| r.save(); |
| r.clipPath(clip.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| r.drawPath(path.get(), paint.get()); |
| r.save(); |
| r.clipPath(clip.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto out = r.finalize(64, 64); |
| auto b = body(out); |
| |
| REQUIRE(countOccurrences(out, "<clipPath") == 1); |
| REQUIRE(countOccurrences(b, "<g clip-path=\"url(#clip0)\">") == 2); |
| REQUIRE(countOccurrences(b, "</g>") == 2); |
| REQUIRE(countOccurrences(b, "<path") == 3); |
| } |
| |
| TEST_CASE("SVGRenderer: same geometry under different CTMs gets distinct defs", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| |
| auto clip = makeSquarePath(f); |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| r.save(); |
| r.transform(rive::Mat2D(2, 0, 0, 2, 0, 0)); |
| r.clipPath(clip.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| r.save(); |
| r.clipPath(clip.get()); // identity CTM: different baked transform |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto out = r.finalize(64, 64); |
| REQUIRE(countOccurrences(out, "<clipPath") == 2); |
| REQUIRE(out.find("<clipPath id=\"clip0\">") != std::string::npos); |
| REQUIRE(out.find("<clipPath id=\"clip1\">") != std::string::npos); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: adjacent scopes sharing a clip merge into a single group", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| |
| auto clip = makeSquarePath(f); |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| // Three consecutive per-drawable clip scopes with identical geometry and |
| // nothing drawn between them — the per-object pattern the runtime emits |
| // when a Rive group is clipped. Expect one <g> containing all three. |
| for (int i = 0; i < 3; i++) |
| { |
| r.save(); |
| r.clipPath(clip.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| } |
| |
| auto out = r.finalize(64, 64); |
| auto b = body(out); |
| |
| REQUIRE(countOccurrences(out, "<clipPath") == 1); |
| REQUIRE(countOccurrences(b, "<g clip-path=\"url(#clip0)\">") == 1); |
| REQUIRE(countOccurrences(b, "</g>") == 1); |
| REQUIRE(countOccurrences(b, "<path") == 3); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: clipped stroke with transformAffectsStroke on keeps its " |
| "width, matrix applied once", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| |
| auto clip = makeSquarePath(f); |
| auto path = makeSquarePath(f); |
| auto paint = makeStrokePaint(f, 0xFF00FF00, 4.0f); |
| |
| // transformAffectsStroke on = local path: the paint saves and applies the |
| // shape's world transform before drawing. |
| r.save(); |
| r.transform(rive::Mat2D(2, 0, 0, 2, 10, 10)); // align |
| r.save(); |
| r.clipPath(clip.get()); |
| r.save(); |
| r.transform(rive::Mat2D(3, 0, 0, 3, 5, 7)); // shape world transform |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| r.restore(); |
| r.restore(); |
| |
| auto b = body(r.finalize(64, 64)); |
| |
| // align * world exactly once; width in local units, scaled by the matrix. |
| REQUIRE(countOccurrences(b, "transform=\"matrix(6 0 0 6 20 24)\"") == 1); |
| REQUIRE(b.find("stroke-width=\"4\"") != std::string::npos); |
| REQUIRE(b.find("<g clip-path=\"url(#clip0)\">") != std::string::npos); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: clipped stroke with transformAffectsStroke off draws the " |
| "world path in the clip entry", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| |
| auto clip = makeSquarePath(f); |
| auto path = makeSquarePath(f); // stands in for the pre-transformed |
| // world-space path |
| auto paint = makeStrokePaint(f, 0xFF00FF00, 4.0f); |
| |
| // transformAffectsStroke off = world path: no save/transform around the |
| // draw, so it lands directly in the clip's save block with CTM = align. |
| r.save(); |
| r.transform(rive::Mat2D(2, 0, 0, 2, 10, 10)); // align |
| r.save(); |
| r.clipPath(clip.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| r.restore(); |
| |
| auto out = r.finalize(64, 64); |
| auto b = body(out); |
| |
| // The stroke path carries only the align matrix (once in the body; the |
| // clip def carries its own copy), and sits inside the clip group. |
| REQUIRE(countOccurrences(b, "transform=\"matrix(2 0 0 2 10 10)\"") == 1); |
| REQUIRE(b.find("stroke-width=\"4\"") != std::string::npos); |
| auto groupOpen = b.find("<g clip-path=\"url(#clip0)\">"); |
| auto strokePath = b.find("stroke-width"); |
| auto groupClose = b.find("</g>"); |
| REQUIRE(groupOpen != std::string::npos); |
| REQUIRE(strokePath != std::string::npos); |
| REQUIRE(groupClose != std::string::npos); |
| REQUIRE(groupOpen < strokePath); |
| REQUIRE(strokePath < groupClose); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: single non-srcOver blend draw inlines style on the path", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000, rive::BlendMode::multiply); |
| |
| r.save(); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto b = body(r.finalize(8, 8)); |
| REQUIRE(b.find("style=\"mix-blend-mode:multiply\"") != std::string::npos); |
| REQUIRE(countOccurrences(b, "<g") == 0); |
| REQUIRE(countOccurrences(b, "<path") == 1); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: multiple shared-blend draws inside one save block wrap in a <g>", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000, rive::BlendMode::multiply); |
| |
| r.save(); |
| r.drawPath(path.get(), paint.get()); |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto b = body(r.finalize(8, 8)); |
| REQUIRE(countOccurrences(b, "<g style=\"mix-blend-mode:multiply\">") == 1); |
| REQUIRE(countOccurrences(b, "</g>") == 1); |
| REQUIRE(countOccurrences(b, "<path") == 2); |
| // Children should NOT have inline blend styles when wrapped in a group. |
| REQUIRE(b.find(" d=") != std::string::npos); |
| auto pathStart = b.find("<path"); |
| auto pathEnd = b.find("/>", pathStart); |
| REQUIRE(pathEnd != std::string::npos); |
| auto firstPath = b.substr(pathStart, pathEnd - pathStart); |
| REQUIRE(firstPath.find("mix-blend-mode") == std::string::npos); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: identity-only outer save (extractor pattern) produces no wrappers", |
| "[svg_renderer]") |
| { |
| SVGFactory f; |
| SVGRenderer r; |
| auto path = makeSquarePath(f); |
| auto paint = makeFillPaint(f, 0xFFFF0000); |
| |
| // Mirrors what svg_extractor.cpp does per frame: save, identity align, |
| // draw, restore. Should produce a flat single <path>. |
| r.save(); |
| r.transform(rive::Mat2D()); // identity |
| r.drawPath(path.get(), paint.get()); |
| r.restore(); |
| |
| auto b = body(r.finalize(64, 64)); |
| REQUIRE(countOccurrences(b, "<g") == 0); |
| REQUIRE(countOccurrences(b, "<path") == 1); |
| } |
| |
| TEST_CASE( |
| "SVGRenderer: svg_clip_test.riv renders a clipped group as one def and one " |
| "merged <g>", |
| "[svg_renderer]") |
| { |
| // A rectangle clips a group of 4 ellipses. The runtime clips per |
| // drawable, so without dedup + adjacent-scope merging this produced 4 |
| // identical defs and 4 single-ellipse groups. |
| SVGFactory factory; |
| auto file = ReadRiveFile("assets/svg_clip_test.riv", &factory); |
| auto artboard = file->artboardDefault(); |
| REQUIRE(artboard != nullptr); |
| artboard->advance(0.0f); |
| |
| SVGRenderer r; |
| artboard->draw(&r); |
| auto out = r.finalize((int)artboard->width(), (int)artboard->height()); |
| auto b = body(out); |
| |
| REQUIRE(countOccurrences(out, "<clipPath") == 1); |
| REQUIRE(countOccurrences(b, "<g clip-path=\"url(#clip0)\">") == 1); |
| REQUIRE(countOccurrences(b, "</g>") == 1); |
| |
| // 4 ellipses inside the clip group, background outside it. |
| auto open = b.find("<g clip-path=\"url(#clip0)\">"); |
| auto close = b.find("</g>"); |
| REQUIRE(open != std::string::npos); |
| REQUIRE(close != std::string::npos); |
| REQUIRE(countOccurrences(b.substr(open, close - open), "<path") == 4); |
| REQUIRE(countOccurrences(b, "<path") == 5); |
| } |