moonlight-planar-1.1.0.0: test/illustration/Moonlight/Planar/IllustrationSpec.hs
module Moonlight.Planar.IllustrationSpec (tests) where
import Data.Bifunctor (first)
import Data.Either (isLeft)
import Data.Foldable (traverse_)
import Data.List (isInfixOf, isPrefixOf, tails)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Sequence as Seq
import Moonlight.Planar.Affine (affine2, identityAffine2)
import Moonlight.Planar.Curve
( ClosedTrail, Located, Subpath (..), circle, closeWith, cubic, curveStep
, line, locate, openTrail, path, quadratic )
import Moonlight.Planar.Exact
( ExactRational, ExactVector (..), exactPoint, exactHalf
, exactThird, positiveExact, positiveOne, positiveTwo, unitHalf, unitOne, unitZero )
import Moonlight.Planar.Illustration
( Color (..), FillRule (..), GradientStop (..), IllustrationError (..)
, LineCap (..), LineJoin (..), Paint (..), Picture, StrokeStyle (..)
, StrokeUnits (..), annotate, clip, fill, miterLimit, miterLimitFour, opacity
, place, stroke, gradientStops, gradientStopValues )
import Moonlight.Planar.Illustration.Svg
( SvgError (..), SvgOptions, renderDiagnosticSvg, renderSvg
, svgOptions, svgPrecision, svgViewport )
tests :: IO ()
tests = traverse_ run cases
where
run :: (String, Either String ()) -> IO ()
run (name, result) = either (fail . ((name <> ": ") <>)) pure result
cases :: [(String, Either String ())]
cases =
[ ("ordered monoid", orderedMonoid)
, ("native polynomial paths", nativePaths)
, ("compound fill and scoped clip", compoundClip)
, ("group opacity is not distributed", groupedOpacity)
, ("stroke units and joins", strokeSemantics)
, ("gradient definitions and stable IDs", gradientSemantics)
, ("annotations escaped and diagnosed", annotations)
, ("XML control characters refused", invalidXml)
, ("conics bounded or refused", conicPublication)
, ("affine output metric controls subdivision", affineSubdivision)
, ("decimal precision and range refusals", precisionRefusals)
, ("miter refinement", miterRefinement)
]
assert :: String -> Bool -> Either String ()
assert message condition = if condition then Right () else Left message
checked :: Show e => Either e a -> Either String a
checked = first show
options :: Int -> ExactRational -> ExactRational -> Int -> Int -> Either String SvgOptions
options digits numericTolerance curveTolerance depth leaves = do
extent <- checked (positiveExact 100)
viewport <- checked (svgViewport 100 100 (exactPoint 0 0) extent extent)
numeric <- checked (positiveExact numericTolerance)
precision <- checked (svgPrecision digits numeric)
curves <- checked (positiveExact curveTolerance)
checked (svgOptions viewport precision curves depth leaves)
standard :: Either String SvgOptions
standard = options 6 (exactHalf ^ (18 :: Int)) (exactHalf ^ (3 :: Int)) 18 10000
render :: Picture String -> Either String String
render picture = standard >>= \config -> checked (renderSvg id config picture)
square :: Located ClosedTrail
square = locate (exactPoint 0 0) $ closeWith line $ openTrail $ Seq.fromList
[ curveStep line (ExactVector 10 0)
, curveStep line (ExactVector 0 10)
, curveStep line (ExactVector (-10) 0)
]
red, blue :: Picture String
red = fill NonZero (Solid (RGB 255 0 0)) (square :| [])
blue = fill NonZero (Solid (RGB 0 0 255)) (square :| [])
orderedMonoid :: Either String ()
orderedMonoid = do
assert "picture monoid associativity" ((red <> blue) <> red == red <> (blue <> red))
assert "picture monoid identities" (mempty <> red == red && red <> mempty == red)
let named = annotate "horn" red
assert "annotation functor identity" (fmap id named == named)
assert "annotation functor composition"
(fmap (("left." <>) . (<> ".tip")) named == (fmap ("left." <>) . fmap (<> ".tip")) named)
forward <- render (red <> blue)
backward <- render (blue <> red)
assert "painter order was discarded" (forward /= backward)
assert "red must paint before blue" ("fill=\"#ff0000\" fill-rule=\"nonzero\"/><path" `isInfixOf` forward)
style :: StrokeUnits -> StrokeStyle
style units = StrokeStyle (Solid (RGB 0 0 0)) positiveOne units RoundCap (MiterJoin miterLimitFour)
nativePaths :: Either String ()
nativePaths = do
let curve = path $ Seq.singleton $ OpenSubpath $ locate (exactPoint 0 0) $ openTrail $ Seq.fromList
[ curveStep (quadratic (ExactVector 3 4)) (ExactVector 6 0)
, curveStep (cubic (ExactVector 1 2) (ExactVector 3 2)) (ExactVector 4 0)
]
svg <- render (stroke (style LocalUnits) curve)
assert "quadratic was not retained" ("Q 3.000000 4.000000 6.000000 0.000000" `isInfixOf` svg)
assert "cubic controls lost their local origin" ("C 7.000000 2.000000 9.000000 2.000000 10.000000 0.000000" `isInfixOf` svg)
compoundClip :: Either String ()
compoundClip = do
let hole = locate (exactPoint 3 3) $ closeWith line $ openTrail $ Seq.fromList
[curveStep line (ExactVector 2 0), curveStep line (ExactVector 0 2), curveStep line (ExactVector (-2) 0)]
contours = square :| [hole]
svg <- render (clip EvenOdd contours (fill EvenOdd (Solid (RGB 4 5 6)) contours) <> blue)
assert "compound path lost a contour" (count "M 3.000000 3.000000" svg == 2)
assert "clip and fill winding differ" ("clip-rule=\"evenodd\"" `isInfixOf` svg && "fill-rule=\"evenodd\"" `isInfixOf` svg)
assert "clip was not scoped" ("</g><path" `isInfixOf` svg)
groupedOpacity :: Either String ()
groupedOpacity = do
svg <- render (opacity unitHalf (red <> blue))
assert "opacity duplicated onto children" (count "opacity=" svg == 1)
assert "opacity lost its group" ("<g opacity=\"0.500000\"><path" `isInfixOf` svg)
strokeSemantics :: Either String ()
strokeSemantics = do
let curve = path (Seq.singleton (ClosedSubpath square))
transform = affine2 (ExactVector 2 0) (ExactVector 0 3) (ExactVector 7 11)
svg <- render (place transform (stroke (style LocalUnits) curve <> stroke (style OutputUnits) curve))
assert "output stroke scaling not isolated" (count "non-scaling-stroke" svg == 1)
assert "cap or miter lost" (count "stroke-linecap=\"round\"" svg == 2 && count "stroke-miterlimit=\"4.000000\"" svg == 2)
assert "affine coefficient order incorrect" ("matrix(2.000000 0.000000 0.000000 3.000000 7.000000 11.000000)" `isInfixOf` svg)
gradientSemantics :: Either String ()
gradientSemantics = do
let start = GradientStop unitZero (RGB 1 2 3) unitOne
end = GradientStop unitOne (RGB 4 5 6) unitHalf
final = GradientStop unitOne (RGB 7 8 9) unitOne
stops = gradientStops (end :| [start, final])
linear :: Picture String
linear = fill NonZero (LinearGradient (exactPoint 0 0) (exactPoint 10 0) stops) (square :| [])
radial :: Picture String
radial = fill NonZero (RadialGradient (exactPoint 5 5) positiveTwo stops) (square :| [])
picture = annotate "same" linear <> annotate "same" radial
assert "gradient canonicalization unstable at equal offsets"
(gradientStopValues stops == start :| [end, final])
svg <- render picture
again <- render picture
assert "render nondeterministic" (svg == again)
assert "gradient units not local user space" (count "gradientUnits=\"userSpaceOnUse\"" svg == 2)
assert "gradient IDs not structural and unique"
(count "id=\"paint-0-0-0\"" svg == 1 && count "id=\"paint-1-0-0\"" svg == 1)
assert "gradient reference mismatch" ("fill=\"url(#paint-1-0-0)\"" `isInfixOf` svg)
annotations :: Either String ()
annotations = do
config <- standard
let picture = annotate "horn<&\"'" (place identityAffine2 red)
svg <- checked (renderSvg id config picture)
diagnostic <- checked (renderDiagnosticSvg id config picture)
assert "label was not escaped" ("horn<&"'" `isInfixOf` svg)
assert "diagnostic did not retain artwork" ("fill=\"#ff0000\"" `isInfixOf` diagnostic)
assert "diagnostic lacks controls/axes/part caption" (all (`isInfixOf` diagnostic) ["polyline", "#288e94", "<text", "authoring guides"])
assert "final artifact leaked guides" (not ("authoring guides" `isInfixOf` svg))
invalidXml :: Either String ()
invalidXml = do
config <- standard
case renderSvg id config (annotate "bad\NULlabel" red) of
Left (SvgInvalidXmlCharacter '\NUL') -> Right ()
outcome -> Left ("expected XML character refusal, got " <> show outcome)
conicPublication :: Either String ()
conicPublication = do
let disc :: Picture String
disc = fill NonZero (Solid (RGB 1 1 1)) (circle positiveTwo :| [])
svg <- render disc
assert "conic fallback not polygonal" ("L " `isInfixOf` svg && not ("Q " `isInfixOf` svg))
config <- options 6 (exactHalf ^ (18 :: Int)) (exactHalf ^ (10 :: Int)) 0 1
case renderSvg id config disc of
Left (SvgLoweringRefused _) -> Right ()
outcome -> Left ("expected conic budget refusal, got " <> show outcome)
affineSubdivision :: Either String ()
affineSubdivision = do
let disc :: Picture String
disc = fill NonZero (Solid (RGB 1 1 1)) (circle positiveTwo :| [])
enlarge = affine2 (ExactVector 100 0) (ExactVector 0 100) (ExactVector 0 0)
local <- render disc
scaled <- render (place enlarge disc)
assert "conic budget ignored output transform" (count "L " scaled > count "L " local)
precisionRefusals :: Either String ()
precisionRefusals = do
strict <- options 2 (exactHalf ^ (20 :: Int)) 1 18 10000
let shifted = affine2 (ExactVector 1 0) (ExactVector 0 1) (ExactVector exactThird 0)
assert "inadequate decimal precision accepted" $ case renderSvg id strict (place shifted red) of
Left (SvgNumericPrecisionRefused _ 2) -> True
_ -> False
config <- standard
let huge = affine2 (ExactVector 1 0) (ExactVector 0 1) (ExactVector (10 ^ (310 :: Int)) 0)
assert "nonfinite browser scalar accepted" $ case renderSvg id config (place huge red) of
Left (SvgNumericRangeRefused _) -> True
_ -> False
tiny <- checked (positiveExact (exactHalf ^ (30 :: Int)))
let tinyStroke = (style LocalUnits) { strokeWidth = tiny }
assert "positive stroke rounded to zero" $ case renderSvg id config (stroke tinyStroke (path (Seq.singleton (ClosedSubpath square)))) of
Left (SvgPositiveValueRoundedToZero _) -> True
_ -> False
miterRefinement :: Either String ()
miterRefinement = do
assert "miter limit below one accepted" (miterLimit exactHalf == Left (InvalidMiterLimit exactHalf))
assert "miter limit one refused" (not (isLeft (miterLimit 1)))
count :: String -> String -> Int
count needle = length . filter (isPrefixOf needle) . tails