packages feed

nano-svg-0.1.0.0: lib/Graphics/NanoSvg/Path.hs

{-# LANGUAGE BangPatterns #-}

-- |
-- Module      : Graphics.NanoSvg.Path
-- Copyright   : (c) 2026 goolord
-- License     : MIT
--
-- Parse path data and transforms, and convert basic SVG shapes to paths.
--
-- Paths use absolute 'Segment's. Parsing resolves relative coordinates,
-- @H@, @V@, @S@, @T@ and implicit command repetition. Renderers still track
-- the current point and subpath start; transforms are returned separately.
--
-- Malformed path data retains the complete segments before the error.
module Graphics.NanoSvg.Path
  ( -- * Path data
    pathData
  , parsePath

    -- * Transforms
  , transformList
  , parseTransform

    -- * The shape elements
  , rectSegments
  , circleSegments
  , ellipseSegments
  , lineSegments
  , polySegments
  )
where

import Control.Applicative ((<|>))
import Data.ByteString (ByteString)
import Data.Maybe (fromMaybe)
import Data.Word (Word8)
import FlatParse.Basic qualified as F
import Graphics.NanoSvg.Internal.Parser
import Graphics.NanoSvg.Number (coordinatePair, flag, number, numberList)
import Graphics.NanoSvg.Types

--------------------------------------------------------------------------------
-- Path data
--------------------------------------------------------------------------------

-- | The @d@ attribute of a @path@, as absolute segments.
--
-- Always succeeds, stopping at the first unknown or incomplete command.
pathData :: P [Segment]
pathData = walk (St 0x4D origin origin NoCtrl)
  where
    origin = Point 0 0

-- | Parse a @d@ attribute into absolute segments. Ignores a malformed tail;
-- returns @[]@ when no complete segment can be read.
parsePath :: ByteString -> [Segment]
parsePath src = maybe [] fst (runPartial src pathData)

-- | State carried between path commands.
data St = St
  { stCommand :: !Word8
  -- ^ The command to repeat when a run of arguments follows.
  , stCurrent :: !Point
  , stSubpath :: !Point
  -- ^ Where the open subpath began, for @Z@.
  , stControl :: !Ctrl
  }

-- | Previous control point: @S@ reflects only a cubic control point and @T@
-- only a quadratic one. Otherwise, the current point is used.
data Ctrl = NoCtrl | CubicCtrl !Point | QuadCtrl !Point

walk :: St -> P [Segment]
walk st = do
  skipWspComma
  peekByte >>= \case
    Nothing -> pure []
    Just w
      | isAsciiAlpha w -> F.skip 1 *> skipWspComma *> step st {stCommand = w}
      -- A run of arguments repeats the command that introduced it.
      | otherwise -> step st

-- | Read one argument group, stopping the path if it is incomplete.
step :: St -> P [Segment]
step st@(St cmd current@(Point cx cy) subpath ctrl) = case lower cmd of
  0x6D -> ending do
    -- m: moveto
    p <- point
    -- A second pair after a moveto is a lineto, and a relative moveto
    -- makes them relative linetos.
    (MoveTo p :)
      <$> walk
        st
          { stCommand = if relative then 0x6C else 0x4C
          , stCurrent = p
          , stSubpath = p
          , stControl = NoCtrl
          }
  0x6C -> ending do
    -- l: lineto
    p <- point
    (LineTo p :) <$> continue p NoCtrl
  0x68 -> ending do
    -- h: horizontal lineto
    x <- number
    let p = Point (if relative then cx + x else x) cy
    (LineTo p :) <$> continue p NoCtrl
  0x76 -> ending do
    -- v: vertical lineto
    y <- number
    let p = Point cx (if relative then cy + y else y)
    (LineTo p :) <$> continue p NoCtrl
  0x63 -> ending do
    -- c: curveto
    c1 <- point
    skipWspComma
    c2 <- point
    skipWspComma
    p <- point
    (CubicTo c1 c2 p :) <$> continue p (CubicCtrl c2)
  0x73 -> ending do
    -- s: smooth curveto
    c2 <- point
    skipWspComma
    p <- point
    let c1 = case ctrl of
          CubicCtrl q -> reflect current q
          _ -> current
    (CubicTo c1 c2 p :) <$> continue p (CubicCtrl c2)
  0x71 -> ending do
    -- q: quadratic curveto
    c1 <- point
    skipWspComma
    p <- point
    (QuadTo c1 p :) <$> continue p (QuadCtrl c1)
  0x74 -> ending do
    -- t: smooth quadratic curveto
    p <- point
    let c1 = case ctrl of
          QuadCtrl q -> reflect current q
          _ -> current
    (QuadTo c1 p :) <$> continue p (QuadCtrl c1)
  0x61 -> ending do
    -- a: elliptical arc
    rx <- number
    skipWspComma
    ry <- number
    skipWspComma
    rot <- number
    skipWspComma
    -- Single-digit flags need no separators: a1 1 0 00.5.5 has seven arguments.
    large <- flag
    skipWspComma
    sweep <- flag
    skipWspComma
    p <- point
    (ArcTo (abs rx) (abs ry) rot large sweep p :) <$> continue p NoCtrl
  0x7A ->
    -- z: closepath. The current point returns to where the subpath began,
    -- and a bare coordinate pair after it starts a new one.
    (ClosePath :)
      <$> walk
        st
          { stCommand = if relative then 0x6D else 0x4D
          , stCurrent = subpath
          , stControl = NoCtrl
          }
  _ -> pure [] -- Not a command: the path ends.
  where
    relative = cmd >= 0x61
    point = do
      Point x y <- coordinatePair
      pure (if relative then Point (cx + x) (cy + y) else Point x y)
    continue p c = walk st {stCurrent = p, stControl = c}
    reflect (Point x y) (Point qx qy) = Point (2 * x - qx) (2 * y - qy)
    ending p = F.withOption p pure (pure [])

--------------------------------------------------------------------------------
-- Transforms
--------------------------------------------------------------------------------

-- | Compose a transform list in SVG order; the rightmost transform acts first.
--
-- Always succeeds. Unknown functions or wrong argument counts contribute
-- identity if their numeric argument list parses. Malformed syntax ends
-- the list, retaining the transforms already read.
transformList :: P Matrix
transformList = skipWspComma *> go identity
  where
    go m = F.withOption transformItem (\m' -> go (m `multiply` m')) (pure m)

-- | Parse a @transform@ attribute using 'transformList'. Returns 'identity'
-- when no usable transform is read.
parseTransform :: ByteString -> Matrix
parseTransform src = maybe identity fst (runPartial src transformList)

transformItem :: P Matrix
transformItem = do
  name <- takeWhileByte isAsciiAlpha
  skipWsp
  skipSatisfyByte (== 0x28)
  args <- numberList
  skipWsp
  skipSatisfyByte (== 0x29)
  skipWspComma
  pure case name of
    "matrix" | [a, b, c, d, e, f] <- args -> Matrix a b c d e f
    "translate" -> case args of
      [x] -> translation x 0
      [x, y] -> translation x y
      _ -> identity
    "scale" -> case args of
      [k] -> scaling k k
      [sx, sy] -> scaling sx sy
      _ -> identity
    "rotate" -> case args of
      [a] -> rotation a
      [a, cx, cy] ->
        translation cx cy `multiply` rotation a `multiply` translation (-cx) (-cy)
      _ -> identity
    "skewX" | [a] <- args -> Matrix 1 0 (tan (radians a)) 1 0 0
    "skewY" | [a] <- args -> Matrix 1 (tan (radians a)) 0 1 0 0
    _ -> identity
  where
    radians a = a * pi / 180

--------------------------------------------------------------------------------
-- The shape elements
--------------------------------------------------------------------------------

-- | @rectSegments x y width height rx ry@ constructs a closed rectangle.
-- Missing corner radii default to each other, or zero if both are absent.
-- Radii are made nonnegative and capped at half the corresponding side.
-- Nonpositive width or height returns @[]@.
rectSegments ::
  Float -> Float -> Float -> Float -> Maybe Float -> Maybe Float -> [Segment]
rectSegments x y w h mrx mry
  | w <= 0 || h <= 0 = []
  | rx <= 0 || ry <= 0 =
      [ MoveTo (Point x y)
      , LineTo (Point (x + w) y)
      , LineTo (Point (x + w) (y + h))
      , LineTo (Point x (y + h))
      , ClosePath
      ]
  | otherwise =
      [ MoveTo (Point (x + rx) y)
      , LineTo (Point (x + w - rx) y)
      , corner (Point (x + w) (y + ry))
      , LineTo (Point (x + w) (y + h - ry))
      , corner (Point (x + w - rx) (y + h))
      , LineTo (Point (x + rx) (y + h))
      , corner (Point x (y + h - ry))
      , LineTo (Point x (y + ry))
      , corner (Point (x + rx) y)
      , ClosePath
      ]
  where
    rx = min (w / 2) (abs (fromMaybe 0 (mrx <|> mry)))
    ry = min (h / 2) (abs (fromMaybe 0 (mry <|> mrx)))
    -- A quarter ellipse, the short way round, clockwise on screen.
    corner = ArcTo rx ry 0 False True

-- | @circleSegments cx cy radius@ constructs a closed circle from two arcs.
-- A nonpositive radius returns @[]@.
circleSegments :: Float -> Float -> Float -> [Segment]
circleSegments cx cy r = ellipseSegments cx cy r r

-- | @ellipseSegments cx cy rx ry@ constructs a closed ellipse from two arcs.
-- Either radius being nonpositive returns @[]@.
ellipseSegments :: Float -> Float -> Float -> Float -> [Segment]
ellipseSegments cx cy rx ry
  | rx <= 0 || ry <= 0 = []
  | otherwise =
      [ MoveTo (Point (cx + rx) cy)
      , ArcTo rx ry 0 False True (Point (cx - rx) cy)
      , ArcTo rx ry 0 False True (Point (cx + rx) cy)
      , ClosePath
      ]

-- | @lineSegments x1 y1 x2 y2@ constructs an open line between two endpoints.
lineSegments :: Float -> Float -> Float -> Float -> [Segment]
lineSegments x1 y1 x2 y2 = [MoveTo (Point x1 y1), LineTo (Point x2 y2)]

-- | Connect points with straight lines. 'True' appends 'ClosePath' for a
-- polygon; 'False' produces an open polyline. An empty input returns @[]@.
polySegments :: Bool -> [Point] -> [Segment]
polySegments closed = \case
  [] -> []
  p : ps -> MoveTo p : map LineTo ps <> [ClosePath | closed]