nano-svg-0.1.0.0: lib/Graphics/NanoSvg/Types.hs
{-# LANGUAGE DerivingStrategies #-}
-- |
-- Module : Graphics.NanoSvg.Types
-- Copyright : (c) 2026 goolord
-- License : MIT
--
-- The flattened document model used by "Graphics.NanoSvg".
--
-- A t'Document' holds t'Shape's in paint order. Each shape has a path in local
-- coordinates, a t'Matrix' mapping it into the document viewBox, and a
-- resolved t'Style'. Viewport mapping and rendering are the caller's job.
module Graphics.NanoSvg.Types
( -- * Colors
RGBA (..)
, rgba
, rgbaR
, rgbaG
, rgbaB
, rgbaA
, withAlpha
, black
, transparent
-- * Geometry
, Point (..)
, Box (..)
-- * Transforms
, Matrix (..)
, identity
, multiply
, translation
, scaling
, rotation
, transformPoint
, averageScale
-- * Path segments
, Segment (..)
-- * Paint
, Paint (..)
, FillRule (..)
, LineCap (..)
, LineJoin (..)
, Style (..)
, defaultStyle
-- * Shapes
, Shape (..)
-- * Documents
, Document (..)
, documentWidth
, documentHeight
)
where
import Data.Bits (shiftL, shiftR, (.&.), (.|.))
import Data.Char (intToDigit)
import Data.Primitive.SmallArray (SmallArray)
import Data.Word (Word32, Word8)
--------------------------------------------------------------------------------
-- Colors
--------------------------------------------------------------------------------
-- | An opaque or translucent color, packed as @0xRRGGBBAA@.
-- Channels are not premultiplied by alpha.
newtype RGBA = RGBA {rgbaToWord32 :: Word32}
deriving newtype (Eq, Ord)
-- | Shown as @#rrggbbaa@.
instance Show RGBA where
show c = '#' : concatMap hex2 [rgbaR c, rgbaG c, rgbaB c, rgbaA c]
where
hex2 w = [digit (w `shiftR` 4), digit (w .&. 0xF)]
digit = intToDigit . fromIntegral
-- | Pack red, green, blue and alpha. Alpha 0 is transparent, 255 opaque.
{-# INLINE rgba #-}
rgba :: Word8 -> Word8 -> Word8 -> Word8 -> RGBA
rgba r g b a =
RGBA $
(w32 r `shiftL` 24)
.|. (w32 g `shiftL` 16)
.|. (w32 b `shiftL` 8)
.|. w32 a
where
w32 = fromIntegral :: Word8 -> Word32
-- | The red channel.
{-# INLINE rgbaR #-}
rgbaR :: RGBA -> Word8
rgbaR (RGBA w) = fromIntegral (w `shiftR` 24)
-- | The green channel.
{-# INLINE rgbaG #-}
rgbaG :: RGBA -> Word8
rgbaG (RGBA w) = fromIntegral (w `shiftR` 16)
-- | The blue channel.
{-# INLINE rgbaB #-}
rgbaB :: RGBA -> Word8
rgbaB (RGBA w) = fromIntegral (w `shiftR` 8)
-- | The alpha channel.
{-# INLINE rgbaA #-}
rgbaA :: RGBA -> Word8
rgbaA (RGBA w) = fromIntegral w
-- | The color with its alpha replaced.
{-# INLINE withAlpha #-}
withAlpha :: Word8 -> RGBA -> RGBA
withAlpha a (RGBA w) = RGBA ((w .&. 0xFFFFFF00) .|. fromIntegral a)
-- | Opaque black, the initial value of @fill@.
black :: RGBA
black = rgba 0 0 0 255
-- | Zero in every channel.
transparent :: RGBA
transparent = RGBA 0
--------------------------------------------------------------------------------
-- Geometry
--------------------------------------------------------------------------------
-- | A point in user space.
data Point = Point {-# UNPACK #-} !Float {-# UNPACK #-} !Float
deriving stock (Eq, Show)
-- | A rectangle as @x y width height@, the shape of a @viewBox@.
data Box = Box
{ boxX :: {-# UNPACK #-} !Float
, boxY :: {-# UNPACK #-} !Float
, boxW :: {-# UNPACK #-} !Float
, boxH :: {-# UNPACK #-} !Float
}
deriving stock (Eq, Show)
--------------------------------------------------------------------------------
-- Transforms
--------------------------------------------------------------------------------
-- | SVG affine transform @matrix(a b c d e f)@:
--
-- > | a c e |
-- > | b d f |
-- > | 0 0 1 |
--
-- Maps @(x, y)@ to @(a*x + c*y + e, b*x + d*y + f)@.
data Matrix = Matrix
{ matrixA :: {-# UNPACK #-} !Float
, matrixB :: {-# UNPACK #-} !Float
, matrixC :: {-# UNPACK #-} !Float
, matrixD :: {-# UNPACK #-} !Float
, matrixE :: {-# UNPACK #-} !Float
, matrixF :: {-# UNPACK #-} !Float
}
deriving stock (Eq, Show)
-- | Identity transform.
identity :: Matrix
identity = Matrix 1 0 0 1 0 0
-- | Compose transforms: @multiply outer inner@ applies @inner@ first.
{-# INLINE multiply #-}
multiply :: Matrix -> Matrix -> Matrix
multiply (Matrix a b c d e f) (Matrix a' b' c' d' e' f') =
Matrix
(a * a' + c * b')
(b * a' + d * b')
(a * c' + c * d')
(b * c' + d * d')
(a * e' + c * f' + e)
(b * e' + d * f' + f)
-- | @translate(x, y)@.
{-# INLINE translation #-}
translation :: Float -> Float -> Matrix
translation x y = Matrix 1 0 0 1 x y
-- | @scale(sx, sy)@.
{-# INLINE scaling #-}
scaling :: Float -> Float -> Matrix
scaling sx sy = Matrix sx 0 0 sy 0 0
-- | @rotate(a)@, the angle in degrees, clockwise because y points down.
{-# INLINE rotation #-}
rotation :: Float -> Matrix
rotation deg =
let r = deg * pi / 180
s = sin r
c = cos r
in Matrix c s (negate s) c 0 0
-- | Map a point through a transform.
{-# INLINE transformPoint #-}
transformPoint :: Matrix -> Point -> Point
transformPoint (Matrix a b c d e f) (Point x y) =
Point (a * x + c * y + e) (b * x + d * y + f)
-- | Square root of the absolute determinant. Exact as a length scale for
-- uniform scaling; an area-based approximation for nonuniform scaling or
-- shear. Useful when a renderer represents stroke width with one scalar.
{-# INLINE averageScale #-}
averageScale :: Matrix -> Float
averageScale (Matrix a b c d _ _) = sqrt (abs (a * d - b * c))
--------------------------------------------------------------------------------
-- Path segments
--------------------------------------------------------------------------------
-- | A path command in absolute, shape-local coordinates. Relative commands
-- and shorthands are resolved during parsing; 'shapeTransform' is not baked
-- into the points. A renderer still tracks the current point and subpath start.
data Segment
= -- | Start a new subpath.
MoveTo !Point
| -- | Straight line to a point.
LineTo !Point
| -- | Cubic Bezier: first control point, second control point, endpoint.
CubicTo !Point !Point !Point
| -- | Quadratic Bezier: control point, endpoint.
QuadTo !Point !Point
| -- | Elliptical arc: radii, the x-axis rotation in degrees, the
-- large-arc and sweep flags, and the endpoint.
ArcTo
{-# UNPACK #-} !Float
{-# UNPACK #-} !Float
{-# UNPACK #-} !Float
!Bool
!Bool
!Point
| -- | Close the current subpath back to where it started.
ClosePath
deriving stock (Eq, Show)
--------------------------------------------------------------------------------
-- Paint
--------------------------------------------------------------------------------
-- | What fills or strokes a shape.
data Paint
= -- | @none@: draw nothing.
PaintNone
| -- | @currentColor@: a color supplied by the renderer.
PaintCurrent
| -- | A literal color.
PaintColor !RGBA
deriving stock (Eq, Show)
-- | Rule for determining a path's filled interior.
data FillRule = NonZero | EvenOdd
deriving stock (Eq, Show)
-- | Stroke endpoint shape.
data LineCap = CapButt | CapRound | CapSquare
deriving stock (Eq, Show)
-- | Stroke corner shape.
data LineJoin = JoinMiter | JoinRound | JoinBevel
deriving stock (Eq, Show)
-- | Resolved presentation properties in shape-local units.
--
-- An unset fill is distinct from explicit black so a renderer can apply an
-- icon tint. Without a tint, use SVG's default black fill. The default stroke
-- is 'Just' 'PaintNone'. See 'defaultStyle' and 'documentMonochrome'.
data Style = Style
{ styleFill :: !(Maybe Paint)
-- ^ Fill paint; 'Nothing' means unspecified.
, styleStroke :: !(Maybe Paint)
-- ^ Stroke paint; 'Nothing' means unspecified.
, styleStrokeWidth :: {-# UNPACK #-} !Float
-- ^ Stroke width before 'shapeTransform' is applied.
, styleCap :: !LineCap
, styleJoin :: !LineJoin
, styleMiterLimit :: {-# UNPACK #-} !Float
-- ^ Maximum miter length as a multiple of stroke width.
, styleFillRule :: !FillRule
, styleOpacity :: {-# UNPACK #-} !Float
-- ^ Local opacity multiplied by ancestor opacities. This does not preserve
-- SVG group compositing for overlapping shapes.
, styleFillOpacity :: {-# UNPACK #-} !Float
-- ^ Fill opacity, multiplied with paint alpha and 'styleOpacity' when drawn.
, styleStrokeOpacity :: {-# UNPACK #-} !Float
-- ^ Stroke opacity, multiplied with paint alpha and 'styleOpacity' when drawn.
}
deriving stock (Eq, Show)
-- | Initial style: unset fill, no stroke, width 1, butt caps, miter joins,
-- miter limit 4, nonzero fill rule and full opacity.
defaultStyle :: Style
defaultStyle =
Style
{ styleFill = Nothing
, styleStroke = Just PaintNone
, styleStrokeWidth = 1
, styleCap = CapButt
, styleJoin = JoinMiter
, styleMiterLimit = 4
, styleFillRule = NonZero
, styleOpacity = 1
, styleFillOpacity = 1
, styleStrokeOpacity = 1
}
--------------------------------------------------------------------------------
-- Shapes
--------------------------------------------------------------------------------
-- | One drawable path.
data Shape = Shape
{ shapeSegments :: !(SmallArray Segment)
-- ^ Absolute segments in the shape's own coordinates.
, shapeTransform :: !Matrix
-- ^ From those coordinates into the document's 'documentViewBox'.
, shapeStyle :: !Style
-- ^ Presentation properties after inheritance and local declarations.
}
deriving stock (Eq, Show)
--------------------------------------------------------------------------------
-- Documents
--------------------------------------------------------------------------------
-- | A parsed SVG document. Equality compares only 'documentKey', not geometry.
data Document = Document
{ documentViewBox :: !Box
-- ^ Root @viewBox@ when valid. Otherwise, a box at the origin using root
-- dimensions, with 24 for each missing or unresolvable dimension.
, documentSize :: {-# UNPACK #-} !(Float, Float)
-- ^ Requested width and height in user units. Each unresolved root
-- dimension falls back to the corresponding 'documentViewBox' extent.
, documentShapes :: !(SmallArray Shape)
-- ^ Every shape, in paint order.
, documentKey :: {-# UNPACK #-} !Int
-- ^ FNV-1a hash of the source bytes, converted to 'Int'. A cache hint, not
-- a collision-free identifier; its width depends on the platform.
, documentMonochrome :: !Bool
-- ^ No shape has a literal 'PaintColor'. Unset paints, 'PaintNone' and
-- 'PaintCurrent' allow caller tinting. Explicit black counts as a literal
-- color; an empty document is monochrome.
}
-- | Documents are equal when their sources hash the same.
instance Eq Document where
a == b = documentKey a == documentKey b
instance Show Document where
show doc = "<svg " <> show (documentSize doc) <> ">"
-- | The width the document asks to be drawn at.
documentWidth :: Document -> Float
documentWidth = fst . documentSize
-- | The height the document asks to be drawn at.
documentHeight :: Document -> Float
documentHeight = snd . documentSize