From ad00fcfc1fc9cc8192ca8c73159d678c7f117b08 Mon Sep 17 00:00:00 2001 From: Ayan Alam Date: Tue, 15 Sep 2026 11:57:47 +0530 Subject: [PATCH] Add a way to render SVG to a Tag instead of the page Svg.render and Canvas.render always put the result into the document. renderToTag stops before the ScalaTags Tag is turned into a DOM node and hands it back instead, so the caller decides what to do with it. SvgRenderer.renderToTag renders via a hidden element that is removed afterwards, so nothing is left on the page. Measuring text needs a live document, which is why this can't be done with a detached node. Fixes #229 --- CHANGELOG.md | 2 + docs/src/pages/svg/README.md | 12 ++++ .../main/scala/doodle/svg/effect/Canvas.scala | 65 +++++++++++++++---- .../scala/doodle/svg/effect/SvgRenderer.scala | 9 +++ .../main/scala/doodle/svg/algebra/Svg.scala | 14 ++++ .../scala/doodle/svg/effect/SvgSpec.scala | 18 +++++ 6 files changed, 106 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c7537c8..2147e862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Canvas backend supports blending +- SVG backend can render to a `Tag` instead of drawing to the page + ## 0.34 26-Feb-2026 diff --git a/docs/src/pages/svg/README.md b/docs/src/pages/svg/README.md index a088438b..8ba3f219 100644 --- a/docs/src/pages/svg/README.md +++ b/docs/src/pages/svg/README.md @@ -37,6 +37,18 @@ thePicture.drawWithFrame(frame) The rendered SVG will appear where the element is positioned on your web page. +Sometimes you don't want Doodle to put the SVG on the page for you. You might want to add your own elements to it first, or choose yourself when and where it appears. In that case render to a `Tag` instead. + +``` scala +import doodle.svg.effect.SvgRenderer + +SvgRenderer.renderToTag(frame, thePicture) +``` + +This gives you an `IO` of a [ScalaTags](https://com-lihaoyi.github.io/scalatags/) `Tag`, along with the picture's result. Nothing is added to the page. Call `.render` on the `Tag` when you want a DOM element to insert or manipulate. + +The frame's `id` is ignored here, as nothing is drawn to the screen, but its size and background still apply to the SVG you get back. + ## Running on the JVM diff --git a/svg/js/src/main/scala/doodle/svg/effect/Canvas.scala b/svg/js/src/main/scala/doodle/svg/effect/Canvas.scala index 59b32283..8f0b0dff 100644 --- a/svg/js/src/main/scala/doodle/svg/effect/Canvas.scala +++ b/svg/js/src/main/scala/doodle/svg/effect/Canvas.scala @@ -203,6 +203,16 @@ final case class Canvas( a } } + + /** Render the given picture to a scalatags `Tag` instead of drawing it to + * this Canvas, so the caller decides what to do with the result. + * + * Note this Canvas has already put its own root in the document. Use + * `Canvas.offscreen` if you want a Tag without anything being added to the + * page. + */ + def renderToTag[A](picture: Picture[A]): IO[(Tag, A)] = + Svg.renderToTag[Algebra, A](frame, algebra, picture) } object Canvas { def fromFrame( @@ -213,19 +223,46 @@ object Canvas { throw new java.util.NoSuchElementException( s"Doodle SVG Canvas could not be created, as could not find a DOM element with the requested id ${frame.id}" ) - } else { - (Topic[IO, Int], Topic[IO, Point], Topic[IO, Point]) - .mapN { (redrawTopic, mouseClickTopic, mouseMoveTopic) => - Canvas( - target, - frame, - redrawTopic, - mouseClickTopic, - mouseMoveTopic - ) - } - .toResource - .flatMap(canvas => canvas.stream.compile.drain.background.as(canvas)) - } + } else fromTarget(target, frame) } + + /** Create a Canvas that draws into a hidden element, which is removed again + * when the Resource is released. The page the user sees is left unchanged, + * so this is the way to render without drawing to the screen. + * + * The element is hidden with 'visibility', not 'display: none', because + * Firefox will not measure text inside an element that isn't displayed. + */ + def offscreen(frame: Frame): Resource[IO, Canvas] = + Resource + .make(IO { + val target = dom.document.createElement("div") + target.setAttribute( + "style", + "position: absolute; left: -10000px; top: 0; visibility: hidden;" + ) + dom.document.body.appendChild(target) + })(target => + IO { + val _ = dom.document.body.removeChild(target) + } + ) + .flatMap(target => fromTarget(target, frame)) + + private def fromTarget( + target: dom.Node, + frame: Frame + ): Resource[IO, Canvas] = + (Topic[IO, Int], Topic[IO, Point], Topic[IO, Point]) + .mapN { (redrawTopic, mouseClickTopic, mouseMoveTopic) => + Canvas( + target, + frame, + redrawTopic, + mouseClickTopic, + mouseMoveTopic + ) + } + .toResource + .flatMap(canvas => canvas.stream.compile.drain.background.as(canvas)) } diff --git a/svg/js/src/main/scala/doodle/svg/effect/SvgRenderer.scala b/svg/js/src/main/scala/doodle/svg/effect/SvgRenderer.scala index 14f8dd83..b069a55e 100644 --- a/svg/js/src/main/scala/doodle/svg/effect/SvgRenderer.scala +++ b/svg/js/src/main/scala/doodle/svg/effect/SvgRenderer.scala @@ -31,4 +31,13 @@ object SvgRenderer extends Renderer[Algebra, Frame, Canvas] { def render[A](canvas: Canvas)(picture: Picture[A]): IO[A] = canvas.render(picture) + + /** Render a picture to a scalatags `Tag`, leaving the page untouched. The + * caller decides if, when and where the SVG is added to the document. + * + * The frame's `id` is not used, as nothing is drawn to the screen. The rest + * of the frame still determines the size and background of the result. + */ + def renderToTag[A](frame: Frame, picture: Picture[A]): IO[(Tag, A)] = + Canvas.offscreen(frame).use(canvas => canvas.renderToTag(picture)) } diff --git a/svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala b/svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala index d07e4315..094f32d1 100644 --- a/svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala +++ b/svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala @@ -67,6 +67,20 @@ trait SvgModule { self: Base => .map { case (bb, tags, a) => (svgTag(bb, frame)(tags).render, a) } } + /** Render to a scalatags `Tag`, wrapped in a root `` tag, without + * converting it to a DOM element or inserting it into the page. The caller + * can decide if and how to add it to the page, using the ScalaTags API to + * render and manipulate the tag themselves. + */ + def renderToTag[Alg <: self.Algebra, A]( + frame: Frame, + algebra: Alg, + picture: Picture[Alg, A] + ): IO[(Tag, A)] = { + renderWithoutRootTag(algebra, picture) + .map { case (bb, tags, a) => (svgTag(bb, frame)(tags), a) } + } + /** Render to SVG without wrapping with a root tag. */ def renderWithoutRootTag[Alg <: self.Algebra, A]( algebra: Alg, diff --git a/svg/shared/src/test/scala/doodle/svg/effect/SvgSpec.scala b/svg/shared/src/test/scala/doodle/svg/effect/SvgSpec.scala index e7948b80..d2794c31 100644 --- a/svg/shared/src/test/scala/doodle/svg/effect/SvgSpec.scala +++ b/svg/shared/src/test/scala/doodle/svg/effect/SvgSpec.scala @@ -59,6 +59,24 @@ class SvgSpec .assertEquals(expected) } + test("renderToTag wraps the picture's content in a root svg tag") { + val diameter = 10.0 + val circle = new doodle.algebra.Picture[Basic, Unit] { + def apply(implicit algebra: Basic): algebra.Drawing[Unit] = + algebra.strokeColor(algebra.circle(diameter), Color.black) + } + val frame = Frame("test").withSize(100, 100) + + for { + withoutRoot <- Svg.renderWithoutRootTag(algebraInstance, circle) + withRoot <- Svg.renderToTag(frame, algebraInstance, circle) + } yield { + val (bb, tags, _) = withoutRoot + val (tag, _) = withRoot + assertEquals(tag, Svg.svgTag(bb, frame)(tags)) + } + } + test("paths of path elements render correctly") { import doodle.core.PathElement.* val path1 = "M 0,0 M 5,5 L 10,10 C 20,20 30,30 40,40 "