nano-svg-0.1.0.0: lib/Graphics/NanoSvg/Xml.hs
{-# LANGUAGE BangPatterns #-}
-- |
-- Module : Graphics.NanoSvg.Xml
-- Copyright : (c) 2026 goolord
-- License : MIT
--
-- Minimal XML element tree backed by @hexml@.
--
-- Names and unmodified attribute values share the parsed input buffer.
-- SVG-specific preprocessing:
--
-- * Replace a DOCTYPE and leading UTF-8 byte-order mark with spaces before
-- parsing. This preserves byte offsets, but not line numbers within a
-- multiline DOCTYPE. Custom entities are not expanded.
--
-- * Strip namespace prefixes without resolving namespace URIs: @svg:path@
-- becomes @path@ and @xlink:href@ becomes @href@.
--
-- * Decode the five predefined XML entities and decimal or hexadecimal
-- character references in attribute values.
--
-- Text nodes, comments and processing instructions are omitted.
module Graphics.NanoSvg.Xml
( -- * The tree
Element (..)
, Attribute (..)
-- * Parsing
, parseXml
-- * Looking things up
, attribute
, descendants
-- * Pieces
, localName
, decodeEntities
, withoutDoctype
)
where
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.ByteString.Lazy qualified as BL
import Data.Char (chr)
import Data.Maybe (listToMaybe)
import Graphics.NanoSvg.Internal.Parser (hexValue, isDigitByte, isHexDigit)
import Text.XML.Hexml qualified as Hexml
--------------------------------------------------------------------------------
-- The tree
--------------------------------------------------------------------------------
-- | An element with namespace prefixes removed from its name and attributes.
data Element = Element
{ elementName :: !ByteString
, elementAttributes :: ![Attribute]
, elementChildren :: ![Element]
}
deriving (Eq, Show)
-- | An attribute with a local name and entity-decoded UTF-8 value.
data Attribute = Attribute
{ attributeName :: !ByteString
, attributeValue :: !ByteString
}
deriving (Eq, Show)
--------------------------------------------------------------------------------
-- Parsing
--------------------------------------------------------------------------------
-- | Parse top-level elements or return a @hexml@ error. Does not require an
-- @svg@ root. The returned tree may retain the parsed input buffer.
parseXml :: ByteString -> Either String [Element]
parseXml src = case Hexml.parse (withoutDoctype (withoutBom src)) of
Left err -> Left (BC.unpack err)
Right root -> Right (map element (elements root))
where
-- hexml exposes processing instructions as elements; filter them out.
elements node =
[n | n <- Hexml.children node, not (BS.isPrefixOf "<?" (Hexml.outer n))]
element node =
Element
{ elementName = localName (Hexml.name node)
, elementAttributes =
[ Attribute
{ attributeName = localName (Hexml.attributeName a)
, attributeValue = decodeEntities (Hexml.attributeValue a)
}
| a <- Hexml.attributes node
]
, elementChildren = map element (elements node)
}
--------------------------------------------------------------------------------
-- Looking things up
--------------------------------------------------------------------------------
-- | Find the first attribute with the given local name (case-sensitive).
-- On a parsed tree, @"href"@ also matches an original @xlink:href@.
attribute :: ByteString -> Element -> Maybe ByteString
attribute k el = listToMaybe [v | Attribute n v <- elementAttributes el, n == k]
{-# INLINE attribute #-}
-- | Every element in the subtree, the root itself first, in document order.
descendants :: Element -> [Element]
descendants el = el : concatMap descendants (elementChildren el)
--------------------------------------------------------------------------------
-- Pieces
--------------------------------------------------------------------------------
-- | Everything after the last colon: @svg:path@ becomes @path@.
localName :: ByteString -> ByteString
localName n = case BS.elemIndexEnd 0x3A n of
Just i -> BS.drop (i + 1) n
Nothing -> n
-- | The document with a leading UTF-8 byte-order mark replaced by spaces.
withoutBom :: ByteString -> ByteString
withoutBom src
| BS.isPrefixOf "\xEF\xBB\xBF" src = " " <> BS.drop 3 src
| otherwise = src
-- | Replace the first DOCTYPE, including its internal subset, with spaces.
-- Preserves byte offsets, not line numbers. The scanner counts brackets;
-- it does not parse quoted strings or comments inside the declaration.
withoutDoctype :: ByteString -> ByteString
withoutDoctype bytes = case BS.breakSubstring "<!DOCTYPE" bytes of
(_, rest) | BS.null rest -> bytes
(before, rest) -> before <> BS.replicate end 0x20 <> BS.drop end rest
where
end = close (0 :: Int) 0
-- The subset in brackets may itself contain a '>'.
close !depth !k
| k >= BS.length rest = k
| otherwise = case BS.index rest k of
0x5B -> close (depth + 1) (k + 1) -- '['
0x5D -> close (depth - 1) (k + 1) -- ']'
0x3E | depth <= 0 -> k + 1 -- '>'
_ -> close depth (k + 1)
-- | Decode predefined XML entities and numeric references to UTF-8.
-- Unknown or invalid references are left unchanged. Input without @&@
-- is returned without copying.
decodeEntities :: ByteString -> ByteString
decodeEntities v
| BS.notElem 0x26 v = v
| otherwise = BL.toStrict (B.toLazyByteString (go v))
where
go s =
let (before, rest) = BS.break (== 0x26) s
in if BS.null rest
then B.byteString before
else case entity rest of
Just (b, after) -> B.byteString before <> b <> go after
-- Preserve unknown or malformed references literally.
Nothing -> B.byteString before <> B.word8 0x26 <> go (BS.drop 1 rest)
-- | One reference at the front of the input, and what is left after it.
entity :: ByteString -> Maybe (B.Builder, ByteString)
entity s
| Just r <- BS.stripPrefix "&" s = Just (B.word8 0x26, r)
| Just r <- BS.stripPrefix "<" s = Just (B.word8 0x3C, r)
| Just r <- BS.stripPrefix ">" s = Just (B.word8 0x3E, r)
| Just r <- BS.stripPrefix """ s = Just (B.word8 0x22, r)
| Just r <- BS.stripPrefix "'" s = Just (B.word8 0x27, r)
| Just r <- BS.stripPrefix "&#x" s = numeric 16 isHexDigit (fromIntegral . hexValue) r
| Just r <- BS.stripPrefix "&#X" s = numeric 16 isHexDigit (fromIntegral . hexValue) r
| Just r <- BS.stripPrefix "&#" s = numeric 10 isDigitByte (fromIntegral . hexValue) r
| otherwise = Nothing
where
numeric base isPart value r =
let ds = BS.takeWhile isPart r
after = BS.drop (BS.length ds) r
code = BS.foldl' (\acc w -> acc * base + value w) (0 :: Int) ds
in if BS.null ds || not (BS.isPrefixOf ";" after) || not (isScalar code)
then Nothing
else Just (B.charUtf8 (chr code), BS.drop 1 after)
-- Reject NUL, surrogates and out-of-range code points before calling chr.
isScalar c = c > 0 && c <= 0x10FFFF && not (c >= 0xD800 && c <= 0xDFFF)