diff --git a/algebra/shared/src/main/scala/doodle/algebra/Layout.scala b/algebra/shared/src/main/scala/doodle/algebra/Layout.scala index ee2be138..2e0dc452 100644 --- a/algebra/shared/src/main/scala/doodle/algebra/Layout.scala +++ b/algebra/shared/src/main/scala/doodle/algebra/Layout.scala @@ -25,6 +25,8 @@ import doodle.core.Vec trait Layout extends Algebra { + import Layout.Scalar + /** Place the origin of top on the origin of bottom */ def on[A](top: Drawing[A], bottom: Drawing[A])(implicit s: Semigroup[A] @@ -55,6 +57,21 @@ trait Layout extends Algebra { left: Double ): Drawing[A] + /** Expand the bounding box of img by the given amounts, evaluated relative + * to the image's current bounding box. + * + * `top` and `bottom` are evaluated relative to the current bounding box + * height; `left` and `right` are evaluated relative to the current bounding + * box width. + */ + def margin[A]( + img: Drawing[A], + top: Scalar, + right: Scalar, + bottom: Scalar, + left: Scalar + ): Drawing[A] + /** Set the width and height of the given `Drawing's` bounding box to the * given values. The new bounding box has the same origin as the original * bounding box, and extends symmetrically above and below, and left and @@ -62,6 +79,14 @@ trait Layout extends Algebra { */ def size[A](img: Drawing[A], width: Double, height: Double): Drawing[A] + /** Set the width and height of the given `Drawing's` bounding box to values + * evaluated relative to the current bounding box. + * + * `width` is evaluated relative to the current bounding box width, and + * `height` is evaluated relative to the current bounding box height. + */ + def size[A](img: Drawing[A], width: Scalar, height: Scalar): Drawing[A] + // Derived methods def under[A](bottom: Drawing[A], top: Drawing[A])(implicit @@ -104,3 +129,30 @@ trait Layout extends Algebra { def size[A](img: Drawing[A], extent: Double): Drawing[A] = size(img, extent, extent) } + +object Layout { + + /** A scalar magnitude that can be evaluated relative to a baseline. + * + * This is used for layout operations like `margin` and `size`, where we want + * to express absolute values (usually pixels) or values relative to the + * current bounding box (e.g. a fraction of width or height). + */ + sealed trait Scalar { + def eval(baseline: Double): Double + } + + object Scalar { + final case class Absolute(value: Double) extends Scalar { + def eval(baseline: Double): Double = value + } + + /** A fraction of the baseline. For example, `0.1` means 10% of the baseline. */ + final case class Fraction(value: Double) extends Scalar { + def eval(baseline: Double): Double = baseline * value + } + + def absolute(value: Double): Scalar = Absolute(value) + def fraction(value: Double): Scalar = Fraction(value) + } +} diff --git a/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala b/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala index d2a11f91..2bb30e74 100644 --- a/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala +++ b/algebra/shared/src/main/scala/doodle/algebra/generic/GenericLayout.scala @@ -27,6 +27,7 @@ import doodle.core.Transform trait GenericLayout[G[_]] extends Layout { self: GivenApply[G] with Algebra { type Drawing[A] = Finalized[G, A] } => import Renderable.* + import Layout.Scalar def on[A](top: Finalized[G, A], bottom: Finalized[G, A])(implicit s: Semigroup[A] @@ -118,6 +119,28 @@ trait GenericLayout[G[_]] extends Layout { (newBb, rdr) } + def margin[A]( + img: Finalized[G, A], + top: Scalar, + right: Scalar, + bottom: Scalar, + left: Scalar + ): Finalized[G, A] = + img.map { case (bb, rdr) => + val topV = top.eval(bb.height) + val bottomV = bottom.eval(bb.height) + val rightV = right.eval(bb.width) + val leftV = left.eval(bb.width) + + val newBb = BoundingBox( + left = bb.left - leftV, + top = bb.top + topV, + right = bb.right + rightV, + bottom = bb.bottom - bottomV + ) + (newBb, rdr) + } + def size[A]( img: Finalized[G, A], width: Double, @@ -145,4 +168,35 @@ trait GenericLayout[G[_]] extends Layout { (newBb, rdr) } } + + def size[A]( + img: Finalized[G, A], + width: Scalar, + height: Scalar + ): Finalized[G, A] = + img.map { case (bb, rdr) => + val resolvedWidth = width.eval(bb.width) + val resolvedHeight = height.eval(bb.height) + + assert( + resolvedWidth >= 0, + s"Called `size` with a width of ${resolvedWidth}. The bounding box's width must be non-negative." + ) + assert( + resolvedHeight >= 0, + s"Called `size` with a height of ${resolvedHeight}. The bounding box's height must be non-negative." + ) + + val w = resolvedWidth / 2.0 + val h = resolvedHeight / 2.0 + + val newBb = BoundingBox( + left = -w, + top = h, + right = w, + bottom = -h + ) + + (newBb, rdr) + } } diff --git a/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala b/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala index 642e84d1..2c2fcc27 100644 --- a/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala +++ b/algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala @@ -30,6 +30,7 @@ trait LayoutSyntax { implicit class LayoutPictureOps[Alg <: Algebra, A]( picture: Picture[Alg, A] ) { + import Layout.Scalar def on[Alg2 <: Algebra]( that: Picture[Alg2, A] )(implicit s: Semigroup[A]): Picture[Alg with Alg2 with Layout, A] = @@ -151,6 +152,17 @@ trait LayoutSyntax { algebra.margin(picture(algebra), top, right, bottom, left) } + def margin( + top: Scalar, + right: Scalar, + bottom: Scalar, + left: Scalar + ): Picture[Alg with Layout, A] = + new Picture[Alg with Layout, A] { + def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = + algebra.margin(picture(algebra), top, right, bottom, left) + } + def margin(width: Double, height: Double): Picture[Alg with Layout, A] = new Picture[Alg with Layout, A] { def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = @@ -169,6 +181,12 @@ trait LayoutSyntax { algebra.size(picture(algebra), width, height) } + def size(width: Scalar, height: Scalar): Picture[Alg with Layout, A] = + new Picture[Alg with Layout, A] { + def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = + algebra.size(picture(algebra), width, height) + } + def size(extent: Double): Picture[Alg with Layout, A] = new Picture[Alg with Layout, A] { def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] = diff --git a/algebra/shared/src/test/scala/doodle/algebra/generic/LayoutSpec.scala b/algebra/shared/src/test/scala/doodle/algebra/generic/LayoutSpec.scala index 617807de..b4319d02 100644 --- a/algebra/shared/src/test/scala/doodle/algebra/generic/LayoutSpec.scala +++ b/algebra/shared/src/test/scala/doodle/algebra/generic/LayoutSpec.scala @@ -20,6 +20,7 @@ package generic import cats.implicits.* import doodle.algebra.generic.reified.Reification +import doodle.algebra.Layout.Scalar import doodle.core.BoundingBox import doodle.core.Transform as Tx import org.scalacheck.* @@ -245,6 +246,36 @@ object LayoutSpec extends Properties("Layout properties") { } } + property("relative margin expands bounding box by the correct amount") = { + val algebra = TestAlgebra() + val genShape = Generators.finalizedOfDepth(algebra, 5) + val genFraction = Gen.choose[Double](-0.5, 0.5) + + forAllNoShrink(genShape, genFraction, genFraction, genFraction, genFraction) { + (shape, topF, rightF, bottomF, leftF) => + val bb = shape.boundingBox + val newBb = algebra + .margin( + shape, + Scalar.fraction(topF), + Scalar.fraction(rightF), + Scalar.fraction(bottomF), + Scalar.fraction(leftF) + ) + .boundingBox + + val top = bb.height * topF + val bottom = bb.height * bottomF + val right = bb.width * rightF + val left = bb.width * leftF + + (newBb.left ?= bb.left - left) && + (newBb.top ?= bb.top + top) && + (newBb.right ?= bb.right + right) && + (newBb.bottom ?= bb.bottom - bottom) + } + } + property("size sets bounding box to the correct size") = { val algebra = TestAlgebra() val genShape = Generators.finalizedOfDepth(algebra, 5) @@ -261,4 +292,25 @@ object LayoutSpec extends Properties("Layout properties") { (newBb.bottom ?= -(height / 2)) } } + + property("relative size sets bounding box to the correct size") = { + val algebra = TestAlgebra() + val genShape = Generators.finalizedOfDepth(algebra, 5) + val genFraction = Gen.choose[Double](0.0, 2.0) + + forAllNoShrink(genShape, genFraction, genFraction) { (shape, wF, hF) => + val bb = shape.boundingBox + val width = bb.width * wF + val height = bb.height * hF + + val newBb = algebra + .size(shape, Scalar.fraction(wF), Scalar.fraction(hF)) + .boundingBox + + (newBb.left ?= -(width / 2)) && + (newBb.right ?= (width / 2)) && + (newBb.top ?= (height / 2)) && + (newBb.bottom ?= -(height / 2)) + } + } } 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..6187b252 100644 --- a/svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala +++ b/svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala @@ -93,11 +93,16 @@ trait SvgModule { self: Base => svgAttrs.width := w, svgAttrs.height := h, svgAttrs.viewBox := s"${bb.left - border} ${-bb.top - border} ${w} ${h}", - bundle.attrs.style := - "pointer-events: bounding-box; " ++ - frame.background - .map(c => s"background-color: ${Svg.toOklch(c)};") - .getOrElse("") + bundle.attrs.style := { + val parts = List( + Some("pointer-events: bounding-box;"), + frame.background.map(c => + s"background-color: ${Svg.toOklch(c)};" + ), + frame.isolation.map(i => s"isolation: ${i.toCSS};") + ).flatten + parts.mkString(" ") + } ) case Size.FixedSize(w, h) => @@ -106,11 +111,16 @@ trait SvgModule { self: Base => svgAttrs.width := w, svgAttrs.height := h, svgAttrs.viewBox := s"${-w / 2} ${-h / 2} ${w} ${h}", - bundle.attrs.style := - "pointer-events: bounding-box; " ++ - frame.background - .map(c => s"background-color: ${Svg.toOklch(c)};") - .getOrElse("") + bundle.attrs.style := { + val parts = List( + Some("pointer-events: bounding-box;"), + frame.background.map(c => + s"background-color: ${Svg.toOklch(c)};" + ), + frame.isolation.map(i => s"isolation: ${i.toCSS};") + ).flatten + parts.mkString(" ") + } ) } diff --git a/svg/shared/src/main/scala/doodle/svg/effect/Frame.scala b/svg/shared/src/main/scala/doodle/svg/effect/Frame.scala index 23296ad8..8f35ddc7 100644 --- a/svg/shared/src/main/scala/doodle/svg/effect/Frame.scala +++ b/svg/shared/src/main/scala/doodle/svg/effect/Frame.scala @@ -34,7 +34,8 @@ import doodle.core.Color final case class Frame( id: String, size: Size, - background: Option[Color] = None + background: Option[Color] = None, + isolation: Option[Isolation] = None ) { /** Use the given color as the background. @@ -42,6 +43,11 @@ final case class Frame( def withBackground(color: Color): Frame = this.copy(background = Some(color)) + /** Set the CSS isolation property on the SVG element. + */ + def withIsolation(isolation: Isolation): Frame = + this.copy(isolation = Some(isolation)) + /** Size the canvas to fit to the picture's bounding box, plus the given * border around the bounding box. */ @@ -54,5 +60,5 @@ final case class Frame( } object Frame { def apply(id: String): Frame = - Frame(id, Size.fitToPicture(), None) + Frame(id, Size.fitToPicture(), None, None) } diff --git a/svg/shared/src/main/scala/doodle/svg/effect/Isolation.scala b/svg/shared/src/main/scala/doodle/svg/effect/Isolation.scala new file mode 100644 index 00000000..72a5e402 --- /dev/null +++ b/svg/shared/src/main/scala/doodle/svg/effect/Isolation.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2015 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package doodle +package svg +package effect + +sealed abstract class Isolation extends Product with Serializable { + def toCSS: String = + this match { + case Isolation.Auto => "auto" + case Isolation.Isolate => "isolate" + } +} +object Isolation { + case object Auto extends Isolation + case object Isolate extends Isolation +} 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..21b2f32f 100644 --- a/svg/shared/src/test/scala/doodle/svg/effect/SvgSpec.scala +++ b/svg/shared/src/test/scala/doodle/svg/effect/SvgSpec.scala @@ -100,23 +100,92 @@ class SvgSpec } - test("paths of points render correctly") { - val path1 = "M 5,5 L 10,10 L 20,20 " - val path2 = "M 5,5 L 10,10 L 20,20 Z" - assertEquals( - Svg.toSvgPath( - Array(Point(5, 5), Point(10, 10), Point(20, 20)), - Svg.Open - ), - path1 + test("svgTag style without isolation matches original output") { + val bb = BoundingBox.centered(100, 100) + val frame = Frame("test").withSizedToPicture() + val tag = Svg.svgTag(bb, frame) + val rendered = tag.toString + + assert( + rendered.contains("""style="pointer-events: bounding-box;""""), + s"Expected no trailing space or extra properties in style, got: $rendered" ) - assertEquals( - Svg - .toSvgPath( - Array(Point(5, 5), Point(10, 10), Point(20, 20)), - Svg.Closed - ), - path2 + assert( + !rendered.contains("isolation:"), + s"Expected no isolation in style, got: $rendered" + ) + } + + test("svgTag style with isolation emits isolation property") { + val bb = BoundingBox.centered(100, 100) + val frame = + Frame("test").withSizedToPicture().withIsolation(Isolation.Isolate) + val tag = Svg.svgTag(bb, frame) + val rendered = tag.toString + + assert( + rendered.contains("isolation: isolate;"), + s"Expected isolation: isolate; in style, got: $rendered" + ) + } + + test("svgTag style with background and isolation emits both") { + val bb = BoundingBox.centered(100, 100) + val frame = Frame("test") + .withSizedToPicture() + .withBackground(Color.white) + .withIsolation(Isolation.Isolate) + val tag = Svg.svgTag(bb, frame) + val rendered = tag.toString + + assert( + rendered.contains("background-color:"), + s"Expected background-color in style, got: $rendered" + ) + assert( + rendered.contains("isolation: isolate;"), + s"Expected isolation: isolate; in style, got: $rendered" + ) + } + + test("svgTag style with Isolation.Auto emits auto") { + val bb = BoundingBox.centered(100, 100) + val frame = Frame("test").withSizedToPicture().withIsolation(Isolation.Auto) + val tag = Svg.svgTag(bb, frame) + val rendered = tag.toString + + assert( + rendered.contains("isolation: auto;"), + s"Expected isolation: auto; in style, got: $rendered" + ) + } + + test("svgTag FixedSize style without isolation has no trailing space") { + val bb = BoundingBox.centered(100, 100) + val frame = Frame("test").withSize(200, 200) + val tag = Svg.svgTag(bb, frame) + val rendered = tag.toString + + assert( + rendered.contains("""style="pointer-events: bounding-box;""""), + s"Expected clean style without trailing space, got: $rendered" + ) + assert( + !rendered.contains("isolation:"), + s"Expected no isolation in style, got: $rendered" + ) + } + + test("svgTag FixedSize style with isolation emits isolation property") { + val bb = BoundingBox.centered(100, 100) + val frame = + Frame("test").withSize(200, 200).withIsolation(Isolation.Isolate) + val tag = Svg.svgTag(bb, frame) + val rendered = tag.toString + + assert( + rendered.contains("isolation: isolate;"), + s"Expected isolation: isolate; in style, got: $rendered" ) } }