packages feed

nano-svg-0.2.0.0: lib/Graphics/NanoSvg.hs

-- |
-- Module      : Graphics.NanoSvg
-- Copyright   : (c) 2026 goolord
-- License     : MIT
--
-- Read an SVG document into a flat array of shapes, each with absolute path
-- segments, a transform and a resolved style. Rendering and viewport mapping
-- are left to the caller.
--
-- > case parseSvg bytes of
-- >   Left err -> putStrLn err
-- >   Right doc -> print (documentSize doc, length (documentShapes doc))
--
-- 'encodeSvg' writes a document back out as SVG, one @path@ per shape.
--
-- = Supported SVG
--
-- The elements @svg@, @g@, @a@, @switch@, @use@, @path@, @rect@, @circle@,
-- @ellipse@, @line@, @polyline@ and @polygon@, with @use@ resolved by @id@
-- and nested at most 'maxUseDepth' deep.
-- The properties @fill@, @stroke@, @stroke-width@, @stroke-linecap@,
-- @stroke-linejoin@, @stroke-miterlimit@, @fill-rule@, @opacity@,
-- @fill-opacity@, @stroke-opacity@, @display@ and @visibility@, as
-- attributes or in a @style@ attribute, which wins. All @transform@
-- functions; hex, @rgb()@, @hsl()@ and the 148 named colors; lengths in
-- absolute units at 96 per inch.
--
-- = Limitations
--
-- Paint servers, @text@, @clipPath@, @mask@, @filter@, @marker@, CSS
-- stylesheets and animation are not supported, and unknown elements are
-- skipped with their children. Group opacity is multiplied into each shape.
-- Nested @svg@ and @symbol@ do not establish viewports, and
-- @preserveAspectRatio@ is left to the renderer.
module Graphics.NanoSvg
  ( parseSvg
  , maxUseDepth
  , encodeSvg
  , svgBuilder
  , module Graphics.NanoSvg.Types
  , module Graphics.NanoSvg.Attribute
  )
where

import Control.Applicative ((<|>))
import Control.Monad (join)
import Data.Bits (shiftR, xor)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as B
import Data.ByteString.Char8 qualified as BC
import Data.Char (chr, toLower)
import Data.Foldable (toList)
import Data.List (find, intersperse)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Primitive.SmallArray (SmallArray, smallArrayFromList)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Word (Word64, Word8)
import FlatParse.Basic qualified as F
import Graphics.NanoSvg.Attribute
import Graphics.NanoSvg.Types
import Text.XML.Hexml qualified as X

-- | Parse UTF-8 SVG bytes. Fails on malformed XML or without an @svg@
-- element. Invalid attribute values are ignored, and malformed paths keep
-- the segments before the error.
parseSvg :: ByteString -> Either String Document
parseSvg src = do
  roots <- either (Left . BC.unpack) (Right . nodes) (X.parse (withoutDoctype src))
  root <- maybe (Left "no svg element") Right (find ((== "svg") . tag) roots)
  let side k = attr k root >>= parseLength
      box = case maybe [] parseNumberList (attr "viewBox" root) of
        [x, y, w, h] | w > 0, h > 0 -> Box x y w h
        _ -> Box 0 0 (fromMaybe 24 (side "width")) (fromMaybe 24 (side "height"))
      -- The first element with an id wins.
      ids = Map.fromListWith (\_ first -> first) [(i, el) | el <- descendants root, Just i <- [attr "id" el]]
      shapes = collect ids Set.empty identity defaultStyle root []
  pure
    Document
      { documentViewBox = box
      , documentSize = (fromMaybe (boxW box) (side "width"), fromMaybe (boxH box) (side "height"))
      , documentShapes = smallArrayFromList shapes
      , documentKey = fromIntegral (BS.foldl' (\h w -> (h `xor` fromIntegral w) * 0x100000001b3) (0xcbf29ce484222325 :: Word64) src)
      , documentMonochrome = null [() | Shape _ _ s <- shapes, Just (PaintColor _) <- [styleFill s, styleStroke s]]
      }

-- | Prepend the shapes an element draws. @open@ holds the @use@ targets
-- being expanded, to cut reference cycles and bound nesting.
collect :: Map ByteString Element -> Set ByteString -> Matrix -> Style -> Element -> [Shape] -> [Shape]
collect ids open outer inherited el rest
  | keyword "display" == Just "none" || keyword "visibility" `elem` [Just "hidden", Just "collapse"] = rest
  | otherwise = case tag el of
      t | t `elem` ["svg", "g", "a"] -> foldr into rest (kids el)
      "switch" -> foldr (\kid next -> case into kid [] of [] -> next; drawn -> drawn <> rest) rest (kids el)
      "use" -> case attr "href" el >>= BS.stripPrefix "#" . BC.strip of
        Just key | Set.size open < maxUseDepth, Set.notMember key open, Just target <- Map.lookup key ids -> do
          let draw = collect ids (Set.insert key open) (transform `multiply` translate (num el "x") (num el "y")) style
          if tag target `elem` ["symbol", "svg"] then foldr draw rest (kids target) else draw target rest
        _ -> rest
      _ | null (geometry el) -> rest
        | otherwise -> Shape (geometry el) transform style : rest
  where
    props = attrs el <> declarations (value "style" el)
    latest = reverse props
    keyword k = lower . BC.strip <$> lookup k latest
    transform = maybe outer (multiply outer . parseTransform) (attr "transform" el)
    own = foldl' property inherited {styleOpacity = 1} props
    style = own {styleOpacity = styleOpacity inherited * styleOpacity own}
    into = collect ids open transform style

-- | How many @use@ elements may nest, counting through their targets; one
-- nested deeper draws nothing. Firefox's @svg.use-element.recursive-clone-limit@
-- is also 8.
maxUseDepth :: Int
maxUseDepth = 8

-- | The segments of a basic shape or path, in its own coordinates.
segments :: Element -> [Segment]
segments el = case tag el of
  "path" -> parsePath (value "d" el)
  "rect" -> rect (num el "x") (num el "y") (num el "width") (num el "height") (len el "rx") (len el "ry")
  "circle" -> ellipse (num el "cx") (num el "cy") (num el "r") (num el "r")
  "ellipse" -> ellipse (num el "cx") (num el "cy") (num el "rx") (num el "ry")
  "line" -> [MoveTo (Point (num el "x1") (num el "y1")), LineTo (Point (num el "x2") (num el "y2"))]
  "polyline" -> poly []
  "polygon" -> poly [ClosePath]
  _ -> []
  where
    poly close = case parsePoints (value "points" el) of
      [] -> []
      p : ps -> MoveTo p : map LineTo ps <> close

value :: ByteString -> Element -> ByteString
value k = fromMaybe "" . attr k

len :: Element -> ByteString -> Maybe Float
len el k = attr k el >>= parseLength

num :: Element -> ByteString -> Float
num el = fromMaybe 0 . len el

rect :: Float -> Float -> Float -> Float -> Maybe Float -> Maybe Float -> [Segment]
rect 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
    -- A missing radius defaults to the other, capped at half the side.
    rx = min (w / 2) (abs (fromMaybe 0 (mrx <|> mry)))
    ry = min (h / 2) (abs (fromMaybe 0 (mry <|> mrx)))
    corner = ArcTo rx ry 0 False True

ellipse :: Float -> Float -> Float -> Float -> [Segment]
ellipse cx cy rx ry
  | rx <= 0 || ry <= 0 = []
  | otherwise = [MoveTo (Point (cx + rx) cy), arc (Point (cx - rx) cy), arc (Point (cx + rx) cy), ClosePath]
  where
    arc = ArcTo rx ry 0 False True

-- | Apply one declaration; invalid or unsupported values leave the style as is.
property :: Style -> (ByteString, ByteString) -> Style
property s (k, v) = fromMaybe s case k of
  "fill" -> (\p -> s {styleFill = Just p}) <$> parsePaint v
  "stroke" -> (\p -> s {styleStroke = Just p}) <$> parsePaint v
  "stroke-width" -> (\w -> s {styleStrokeWidth = max 0 w}) <$> parseLength v
  "stroke-miterlimit" -> (\l -> s {styleMiterLimit = max 1 l}) <$> parseLength v
  "opacity" -> (\o -> s {styleOpacity = o}) <$> parseOpacity v
  "fill-opacity" -> (\o -> s {styleFillOpacity = o}) <$> parseOpacity v
  "stroke-opacity" -> (\o -> s {styleStrokeOpacity = o}) <$> parseOpacity v
  "stroke-linecap" -> (\c -> s {styleCap = c}) <$> lookup kw [("butt", CapButt), ("round", CapRound), ("square", CapSquare)]
  "stroke-linejoin" -> (\j -> s {styleJoin = j}) <$> lookup kw [("miter", JoinMiter), ("round", JoinRound), ("bevel", JoinBevel)]
  "fill-rule" -> (\r -> s {styleFillRule = r}) <$> lookup kw [("nonzero", NonZero), ("evenodd", EvenOdd)]
  _ -> Nothing
  where
    kw = lower (BC.strip v)

-- | Split a @style@ attribute into declarations. Not a CSS parser.
declarations :: ByteString -> [(ByteString, ByteString)]
declarations s =
  [(lower (BC.strip k), BC.strip (BS.drop 1 v)) | d <- BC.split ';' s, let (k, v) = BC.break (== ':') d, not (BS.null v)]

lower :: ByteString -> ByteString
lower = BC.map toLower


--------------------------------------------------------------------------------
-- Encoding
--------------------------------------------------------------------------------

-- | Write a document as UTF-8 SVG. 'parseSvg' reads the result back to the
-- same view box, size and shapes.
encodeSvg :: Document -> ByteString
encodeSvg = BS.toStrict . B.toLazyByteString . svgBuilder

-- | 'encodeSvg' as a 'B.Builder': one @path@ per shape, in paint order, with
-- its transform and each property that differs from 'defaultStyle'.
svgBuilder :: Document -> B.Builder
svgBuilder doc =
  "<svg xmlns=\"http://www.w3.org/2000/svg\""
    <> attribute "width" (number w)
    <> attribute "height" (number h)
    <> attribute "viewBox" (spaced (map number [x, y, bw, bh]))
    <> ">"
    <> foldMap shape (documentShapes doc)
    <> "</svg>"
  where
    Box x y bw bh = documentViewBox doc
    (w, h) = documentSize doc

shape :: Shape -> B.Builder
shape (Shape segs m s) =
  "<path"
    <> attribute "d" (spaced (map segment (toList segs)))
    <> (if m == identity then mempty else attribute "transform" (matrix m))
    <> foldMap (attribute "fill" . paint) (styleFill s)
    <> foldMap (attribute "fill-opacity" . number) (changed styleFillOpacity)
    <> foldMap (attribute "fill-rule" . \case NonZero -> "nonzero"; EvenOdd -> "evenodd") (changed styleFillRule)
    <> foldMap (attribute "stroke" . paint) (join (changed styleStroke))
    <> foldMap (attribute "stroke-opacity" . number) (changed styleStrokeOpacity)
    <> foldMap (attribute "stroke-width" . number) (changed styleStrokeWidth)
    <> foldMap (attribute "stroke-linecap" . \case CapButt -> "butt"; CapRound -> "round"; CapSquare -> "square") (changed styleCap)
    <> foldMap (attribute "stroke-linejoin" . \case JoinMiter -> "miter"; JoinRound -> "round"; JoinBevel -> "bevel") (changed styleJoin)
    <> foldMap (attribute "stroke-miterlimit" . number) (changed styleMiterLimit)
    <> foldMap (attribute "opacity" . number) (changed styleOpacity)
    <> "/>"
  where
    changed :: Eq a => (Style -> a) -> Maybe a
    changed field = if field s == field defaultStyle then Nothing else Just (field s)
    matrix (Matrix a b c d e f) = "matrix(" <> spaced (map number [a, b, c, d, e, f]) <> ")"

segment :: Segment -> B.Builder
segment = \case
  MoveTo p -> "M" <> point p
  LineTo p -> "L" <> point p
  CubicTo p q r -> "C" <> spaced [point p, point q, point r]
  QuadTo p q -> "Q" <> spaced [point p, point q]
  ArcTo rx ry angle large sweep p -> "A" <> spaced [number rx, number ry, number angle, flag large, flag sweep, point p]
  ClosePath -> "Z"
  where
    point (Point px py) = number px <> " " <> number py
    flag b = if b then "1" else "0"

-- | @#rrggbb@, or @#rrggbbaa@ when not opaque.
paint :: Paint -> B.Builder
paint = \case
  PaintNone -> "none"
  PaintCurrent -> "currentColor"
  PaintColor (RGBA c) -> "#" <> foldMap (B.word8HexFixed . channel) (if channel 0 == 255 then [24, 16, 8] else [24, 16, 8, 0])
    where
      channel k = fromIntegral (c `shiftR` k) :: Word8

-- | The shortest digits that read back as the same 'Float', and an integer
-- without a trailing @.0@.
number :: Float -> B.Builder
number v
  | abs v < 1e7, v == fromIntegral i = B.intDec i
  | otherwise = B.floatDec v
  where
    i = truncate v :: Int

attribute :: B.Builder -> B.Builder -> B.Builder
attribute k v = " " <> k <> "=\"" <> v <> "\""

spaced :: [B.Builder] -> B.Builder
spaced = mconcat . intersperse " "

--------------------------------------------------------------------------------
-- XML
--------------------------------------------------------------------------------

-- | 'geometry' is parsed on first draw and shared by every @use@ of the
-- element.
data Element = Element {tag :: ByteString, attrs :: [(ByteString, ByteString)], geometry :: SmallArray Segment, kids :: [Element]}

attr :: ByteString -> Element -> Maybe ByteString
attr k = lookup k . attrs

descendants :: Element -> [Element]
descendants el = go el []
  where
    go e rest = e : foldr go rest (kids e)

-- | Child elements, without the processing instructions hexml reports.
nodes :: X.Node -> [Element]
nodes n = [element c | c <- X.children n, not (BS.isPrefixOf "<?" (X.outer c))]
  where
    element c = el
      where
        el = Element (local (X.name c)) [(local k, entities v) | X.Attribute k v <- X.attributes c] (smallArrayFromList (segments el)) (nodes c)
    local s = maybe s (\i -> BS.drop (i + 1) s) (BC.elemIndexEnd ':' s)

-- | Remove a DOCTYPE, including an internal subset, which hexml rejects.
withoutDoctype :: ByteString -> ByteString
withoutDoctype s = before <> BC.drop 1 (BC.dropWhile (/= '>') decl)
  where
    (before, doctype) = BS.breakSubstring "<!DOCTYPE" s
    decl = if BC.elem '[' (BC.takeWhile (/= '>') doctype) then BC.dropWhile (/= ']') doctype else doctype

-- | Decode the predefined entities and character references; leave anything
-- else as written. Linear in the length of the value.
entities :: ByteString -> ByteString
entities v
  | BC.notElem '&' v = v
  | otherwise = BS.toStrict (B.toLazyByteString (go v))
  where
    go s = case BC.break (== '&') s of
      (a, b) | BS.null b -> B.byteString a
      (a, b) -> B.byteString a <> case BC.break (\c -> c == ';' || c == '&') (BS.drop 1 b) of
        -- A reference cannot contain an ampersand, so the scan stops at the
        -- next one instead of running on to a distant semicolon.
        (ref, rest) | Just rest' <- BC.stripPrefix ";" rest, Just c <- entity ref -> B.charUtf8 c <> go rest'
        _ -> B.char7 '&' <> go (BS.drop 1 b)
    entity ref = case BC.uncons ref of
      Just ('#', n) -> case BC.uncons n of
        Just (x, h) | toLower x == 'x' -> scalar F.anyAsciiHexInt h
        _ -> scalar F.anyAsciiDecimalInt n
      _ -> lookup ref [("amp", '&'), ("lt", '<'), ("gt", '>'), ("quot", '"'), ("apos", '\'')]
    -- The digit parsers fail on overflow rather than wrap around.
    scalar digits ds = case F.runParser (digits <* F.eof) ds of
      F.OK c _ | c > 0, c <= 0x10FFFF, c < 0xD800 || c > 0xDFFF -> Just (chr c)
      _ -> Nothing