Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions docs/src/pages/svg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 51 additions & 14 deletions svg/js/src/main/scala/doodle/svg/effect/Canvas.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 <svg> 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(
Expand All @@ -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))
}
9 changes: 9 additions & 0 deletions svg/js/src/main/scala/doodle/svg/effect/SvgRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
14 changes: 14 additions & 0 deletions svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg>` 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 <svg> tag. */
def renderWithoutRootTag[Alg <: self.Algebra, A](
algebra: Alg,
Expand Down
18 changes: 18 additions & 0 deletions svg/shared/src/test/scala/doodle/svg/effect/SvgSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down