brillo-export-0.2.0.0: src/Brillo/Export/SVG.hs
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-| Export Brillo pictures to SVG format.
This module provides functions to convert Brillo 'Picture' values
to SVG (Scalable Vector Graphics) format. SVG is a vector graphics
format that can be scaled without loss of quality.
-}
module Brillo.Export.SVG (
-- * Export functions
exportPictureToSVG,
exportPicturesToSVG,
-- * Direct rendering
renderPictureToSVG,
pictureToSVGDoc,
) where
import Brillo.Rendering (
BitmapData (..),
Color,
Picture (..),
Point,
Rectangle (..),
makeColor,
rgbaOfColor,
)
import Codec.Picture qualified as JP
import Data.ByteString.Lazy qualified as BL
import Data.List (intercalate)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Vector.Storable qualified as V
import Data.Word (Word8)
import Foreign.ForeignPtr (withForeignPtr)
import Foreign.Ptr (Ptr, castPtr)
import Foreign.Storable (peekElemOff)
import System.IO.Unsafe (unsafePerformIO)
import Text.Printf (printf)
-- | Size of the output image (width, height) in pixels
type Size = (Int, Int)
-- | An animation function that takes time and returns a Picture
type Animation = Float -> Picture
-- | Export a Brillo Picture to an SVG file.
exportPictureToSVG ::
-- | (width, height) in pixels
Size ->
-- | Background color
Color ->
-- | Output file path
FilePath ->
-- | Picture to export
Picture ->
IO ()
exportPictureToSVG size bgColor filePath picture = do
let svg = pictureToSVGDoc size bgColor picture
TIO.writeFile filePath svg
{-| Export a series of Brillo Pictures to SVG files.
The file path pattern should contain "%d" which will be replaced by the frame number.
-}
exportPicturesToSVG ::
-- | (width, height) in pixels
Size ->
-- | Background color
Color ->
-- | File path pattern (must contain "%d")
FilePath ->
-- | Animation function
Animation ->
-- | List of time points
[Float] ->
IO ()
exportPicturesToSVG size bgColor filePathPattern animation times = do
mapM_ exportFrame (zip [1 ..] times)
where
exportFrame (n :: Int, t) = do
let picture = animation t
let filePath = printf filePathPattern n
exportPictureToSVG size bgColor filePath picture
{-| Render a Picture to SVG text.
Returns just the SVG content without the XML declaration and doctype.
-}
renderPictureToSVG ::
-- | (width, height) in pixels
Size ->
-- | Background color
Color ->
-- | Picture to render
Picture ->
Text
renderPictureToSVG (width, height) bgColor picture =
let
-- SVG coordinate system has origin at top-left, y increases downward
-- Brillo has origin at center, y increases upward
-- We need to transform accordingly
halfW = fromIntegral width / 2 :: Float
halfH = fromIntegral height / 2 :: Float
-- Create the transformed picture (flip y-axis and translate origin)
transformedPicture = Translate halfW halfH (Scale 1 (-1) picture)
-- Render background
bgRect = renderBackground width height bgColor
-- Render the picture content
content = renderPicture defaultRenderState transformedPicture
in
T.concat [bgRect, content]
-- | Convert a Picture to a complete SVG document.
pictureToSVGDoc ::
-- | (width, height) in pixels
Size ->
-- | Background color
Color ->
-- | Picture to convert
Picture ->
Text
pictureToSVGDoc size@(width, height) bgColor picture =
T.concat
[ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
, "<svg xmlns=\"http://www.w3.org/2000/svg\" "
, "xmlns:xlink=\"http://www.w3.org/1999/xlink\" "
, "width=\""
, T.pack (show width)
, "\" height=\""
, T.pack (show height)
, "\" viewBox=\"0 0 "
, T.pack (show width)
, " "
, T.pack (show height)
, "\">\n"
, renderPictureToSVG size bgColor picture
, "</svg>\n"
]
-- | State for rendering, tracks current color
data RenderState = RenderState
{ rsColor :: Color
-- ^ Current fill/stroke color
}
-- | Default render state with black color
defaultRenderState :: RenderState
defaultRenderState =
RenderState
{ rsColor = makeColor 0 0 0 1
}
-- | Render the background rectangle
renderBackground :: Int -> Int -> Color -> Text
renderBackground width height bgColor =
T.concat
[ " <rect x=\"0\" y=\"0\" width=\""
, T.pack (show width)
, "\" height=\""
, T.pack (show height)
, "\" fill=\""
, colorToSVG bgColor
, "\"/>\n"
]
-- | Convert a Brillo Color to SVG color string
colorToSVG :: Color -> Text
colorToSVG color =
let (r, g, b, a) = rgbaOfColor color
ri = round (r * 255) :: Int
gi = round (g * 255) :: Int
bi = round (b * 255) :: Int
in if a >= 1.0
then T.pack $ printf "rgb(%d,%d,%d)" ri gi bi
else T.pack $ printf "rgba(%d,%d,%d,%.3f)" ri gi bi a
-- | Get opacity from color
colorOpacity :: Color -> Float
colorOpacity color =
let (_, _, _, a) = rgbaOfColor color
in a
-- | Main picture rendering function
renderPicture :: RenderState -> Picture -> Text
renderPicture _ Blank = ""
renderPicture rs (Polygon path) = renderPolygonSmooth rs path
renderPicture rs (PolygonAliased path) = renderPolygon rs path
renderPicture rs (Line path) = renderLine rs path 1.0
renderPicture rs (LineAliased path) = renderSmoothLine rs path 1.0
renderPicture rs (ThickLine path thickness) = renderLine rs path thickness
renderPicture rs (ThickLineAliased path thickness) = renderSmoothLine rs path thickness
renderPicture rs (Circle radius) = renderCircleSmooth rs radius 1.0
renderPicture rs (CircleAliased radius) = renderCircle rs radius 1.0
renderPicture rs (ThickCircle radius thickness) = renderThickCircleSmooth rs radius thickness
renderPicture rs (ThickCircleAliased radius thickness) = renderThickCircle rs radius thickness
renderPicture rs (Arc startAngle endAngle radius) = renderArcSmooth rs startAngle endAngle radius 1.0
renderPicture rs (ArcAliased startAngle endAngle radius) = renderArc rs startAngle endAngle radius 1.0
renderPicture rs (ThickArc startAngle endAngle thickness radius) = renderArcSmooth rs startAngle endAngle radius thickness
renderPicture rs (ThickArcAliased startAngle endAngle thickness radius) = renderArc rs startAngle endAngle radius thickness
renderPicture rs (Text txt) = renderTextSmooth rs txt 1.0
renderPicture rs (TextAliased txt) = renderText rs txt 1.0
renderPicture rs (ThickText txt thickness) = renderTextSmooth rs txt thickness
renderPicture rs (ThickTextAliased txt thickness) = renderText rs txt thickness
renderPicture rs (TrueTypeText _fontPath _size txt) = renderText rs txt 1.0
renderPicture rs (Bitmap bitmapData) = renderBitmap rs bitmapData Nothing
renderPicture rs (BitmapSection rect bitmapData) = renderBitmap rs bitmapData (Just rect)
renderPicture rs (Color color picture) =
renderPicture (rs{rsColor = color}) picture
renderPicture rs (Translate dx dy picture) =
T.concat
[ " <g transform=\"translate("
, T.pack (showFloat dx)
, ","
, T.pack (showFloat dy)
, ")\">\n"
, renderPicture rs picture
, " </g>\n"
]
renderPicture rs (Rotate angle picture) =
-- Brillo uses clockwise rotation in degrees
-- SVG uses clockwise rotation in degrees too
T.concat
[ " <g transform=\"rotate("
, T.pack (showFloat (-angle))
, ")\">\n"
, renderPicture rs picture
, " </g>\n"
]
renderPicture rs (Scale sx sy picture) =
T.concat
[ " <g transform=\"scale("
, T.pack (showFloat sx)
, ","
, T.pack (showFloat sy)
, ")\">\n"
, renderPicture rs picture
, " </g>\n"
]
renderPicture rs (Pictures pictures) =
T.concat (map (renderPicture rs) pictures)
-- | Render a filled polygon
renderPolygon :: RenderState -> [Point] -> Text
renderPolygon _ [] = ""
renderPolygon rs points =
T.concat
[ " <polygon points=\""
, T.pack $ unwords [showFloat x ++ "," ++ showFloat y | (x, y) <- points]
, "\" fill=\""
, colorToSVG (rsColor rs)
, "\""
, opacityAttr (rsColor rs)
, "/>\n"
]
-- | Render a filled polygon with anti-aliased edges (uses shape-rendering: geometricPrecision)
renderPolygonSmooth :: RenderState -> [Point] -> Text
renderPolygonSmooth _ [] = ""
renderPolygonSmooth rs points =
T.concat
[ " <polygon points=\""
, T.pack $ unwords [showFloat x ++ "," ++ showFloat y | (x, y) <- points]
, "\" fill=\""
, colorToSVG (rsColor rs)
, "\" shape-rendering=\"geometricPrecision\""
, opacityAttr (rsColor rs)
, "/>\n"
]
-- | Render a line (polyline)
renderLine :: RenderState -> [Point] -> Float -> Text
renderLine _ [] _ = ""
renderLine _ [_] _ = ""
renderLine rs points thickness =
T.concat
[ " <polyline points=\""
, T.pack $ unwords [showFloat x ++ "," ++ showFloat y | (x, y) <- points]
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (max 1.0 thickness))
, "\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
-- | Render a smooth line using cubic bezier curves
renderSmoothLine :: RenderState -> [Point] -> Float -> Text
renderSmoothLine _ [] _ = ""
renderSmoothLine _ [_] _ = ""
renderSmoothLine rs points thickness =
let pathData = smoothPathToSVG points
in T.concat
[ " <path d=\""
, pathData
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (max 1.0 thickness))
, "\" stroke-linecap=\"round\" stroke-linejoin=\"round\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
-- | Convert a path to a smooth SVG path using cubic bezier curves
smoothPathToSVG :: [Point] -> Text
smoothPathToSVG [] = ""
smoothPathToSVG [p] = T.pack $ "M " ++ showPoint p
smoothPathToSVG [p1, p2] = T.pack $ "M " ++ showPoint p1 ++ " L " ++ showPoint p2
smoothPathToSVG (p1 : p2 : rest) =
T.pack $
"M "
++ showPoint p1
++ " "
++ smoothCurves (p1 : p2 : rest)
where
smoothCurves :: [Point] -> String
smoothCurves pts
| length pts < 3 = ""
| otherwise = intercalate " " $ zipWith3 makeCurve pts (drop 1 pts) (drop 2 pts)
makeCurve :: Point -> Point -> Point -> String
makeCurve (_x0, _y0) (x1, y1) (x2, y2) =
let cx2 = (x1 + x2) / 2
cy2 = (y1 + y2) / 2
in "Q "
++ showFloat x1
++ " "
++ showFloat y1
++ " "
++ showFloat cx2
++ " "
++ showFloat cy2
-- | Render a circle outline
renderCircle :: RenderState -> Float -> Float -> Text
renderCircle rs radius thickness =
T.concat
[ " <circle cx=\"0\" cy=\"0\" r=\""
, T.pack (showFloat (abs radius))
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (max 1.0 thickness))
, "\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
-- | Render a circle outline with anti-aliasing
renderCircleSmooth :: RenderState -> Float -> Float -> Text
renderCircleSmooth rs radius thickness =
T.concat
[ " <circle cx=\"0\" cy=\"0\" r=\""
, T.pack (showFloat (abs radius))
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (max 1.0 thickness))
, "\" shape-rendering=\"geometricPrecision\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
{-| Render a thick circle (annulus/ring or filled disc)
ThickCircle has parameters: radius (center of ring) and thickness (width of ring)
Inner radius = radius - thickness/2, Outer radius = radius + thickness/2
-}
renderThickCircle :: RenderState -> Float -> Float -> Text
renderThickCircle rs radius thickness
| thickness <= 0 = renderCircle rs radius 1.0
| otherwise =
let innerRadius = abs radius - thickness / 2
outerRadius = abs radius + thickness / 2
in if innerRadius <= 0
then
-- Inner radius is zero or negative, render as filled circle
T.concat
[ " <circle cx=\"0\" cy=\"0\" r=\""
, T.pack (showFloat outerRadius)
, "\" fill=\""
, colorToSVG (rsColor rs)
, "\""
, opacityAttr (rsColor rs)
, "/>\n"
]
else
-- Render as a ring (stroke)
T.concat
[ " <circle cx=\"0\" cy=\"0\" r=\""
, T.pack (showFloat ((innerRadius + outerRadius) / 2))
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (outerRadius - innerRadius))
, "\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
{-| Render a thick circle (annulus/ring or filled disc) with anti-aliasing
ThickCircle has parameters: radius (center of ring) and thickness (width of ring)
Inner radius = radius - thickness/2, Outer radius = radius + thickness/2
-}
renderThickCircleSmooth :: RenderState -> Float -> Float -> Text
renderThickCircleSmooth rs radius thickness
| thickness <= 0 = renderCircleSmooth rs radius 1.0
| otherwise =
let innerRadius = abs radius - thickness / 2
outerRadius = abs radius + thickness / 2
in if innerRadius <= 0
then
-- Inner radius is zero or negative, render as filled circle
T.concat
[ " <circle cx=\"0\" cy=\"0\" r=\""
, T.pack (showFloat outerRadius)
, "\" fill=\""
, colorToSVG (rsColor rs)
, "\" shape-rendering=\"geometricPrecision\""
, opacityAttr (rsColor rs)
, "/>\n"
]
else
-- Render as a ring (stroke)
T.concat
[ " <circle cx=\"0\" cy=\"0\" r=\""
, T.pack (showFloat ((innerRadius + outerRadius) / 2))
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (outerRadius - innerRadius))
, "\" shape-rendering=\"geometricPrecision\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
-- | Render an arc
renderArc :: RenderState -> Float -> Float -> Float -> Float -> Text
renderArc rs startAngle endAngle radius thickness =
let
-- Convert angles from degrees to radians
-- Brillo uses counter-clockwise from the positive x-axis
-- SVG arcs need special handling
r = abs radius
startRad = startAngle * pi / 180
endRad = endAngle * pi / 180
-- Calculate start and end points
x1 = r * cos startRad
y1 = r * sin startRad
x2 = r * cos endRad
y2 = r * sin endRad
-- Determine arc sweep (large arc flag and sweep direction)
angleDiff = endAngle - startAngle
largeArc = if abs angleDiff > 180 then 1 else 0 :: Int
sweep = if angleDiff > 0 then 1 else 0 :: Int
pathData =
"M "
++ showFloat x1
++ " "
++ showFloat y1
++ " A "
++ showFloat r
++ " "
++ showFloat r
++ " 0 "
++ show largeArc
++ " "
++ show sweep
++ " "
++ showFloat x2
++ " "
++ showFloat y2
in
T.concat
[ " <path d=\""
, T.pack pathData
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (max 1.0 thickness))
, "\" stroke-linecap=\"round\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
-- | Render an arc with anti-aliasing
renderArcSmooth :: RenderState -> Float -> Float -> Float -> Float -> Text
renderArcSmooth rs startAngle endAngle radius thickness =
let
-- Convert angles from degrees to radians
-- Brillo uses counter-clockwise from the positive x-axis
-- SVG arcs need special handling
r = abs radius
startRad = startAngle * pi / 180
endRad = endAngle * pi / 180
-- Calculate start and end points
x1 = r * cos startRad
y1 = r * sin startRad
x2 = r * cos endRad
y2 = r * sin endRad
-- Determine arc sweep (large arc flag and sweep direction)
angleDiff = endAngle - startAngle
largeArc = if abs angleDiff > 180 then 1 else 0 :: Int
sweep = if angleDiff > 0 then 1 else 0 :: Int
pathData =
"M "
++ showFloat x1
++ " "
++ showFloat y1
++ " A "
++ showFloat r
++ " "
++ showFloat r
++ " 0 "
++ show largeArc
++ " "
++ show sweep
++ " "
++ showFloat x2
++ " "
++ showFloat y2
in
T.concat
[ " <path d=\""
, T.pack pathData
, "\" fill=\"none\" stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (max 1.0 thickness))
, "\" stroke-linecap=\"round\" shape-rendering=\"geometricPrecision\""
, strokeOpacityAttr (rsColor rs)
, "/>\n"
]
-- | Render text
renderText :: RenderState -> Text -> Float -> Text
renderText rs txt thickness =
T.concat
[ " <text x=\"0\" y=\"0\" fill=\""
, colorToSVG (rsColor rs)
, "\" font-family=\"monospace\" font-size=\"100\""
, if thickness > 1.0
then
T.concat
[ " stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (thickness - 1.0))
, "\""
]
else ""
, opacityAttr (rsColor rs)
, ">"
, escapeXML txt
, "</text>\n"
]
-- | Render text with anti-aliasing
renderTextSmooth :: RenderState -> Text -> Float -> Text
renderTextSmooth rs txt thickness =
T.concat
[ " <text x=\"0\" y=\"0\" fill=\""
, colorToSVG (rsColor rs)
, "\" font-family=\"monospace\" font-size=\"100\" text-rendering=\"geometricPrecision\""
, if thickness > 1.0
then
T.concat
[ " stroke=\""
, colorToSVG (rsColor rs)
, "\" stroke-width=\""
, T.pack (showFloat (thickness - 1.0))
, "\""
]
else ""
, opacityAttr (rsColor rs)
, ">"
, escapeXML txt
, "</text>\n"
]
-- | Render a bitmap image as a data URI embedded image
renderBitmap :: RenderState -> BitmapData -> Maybe Rectangle -> Text
renderBitmap _ bitmapData maybeRect =
let
BitmapData _len _fmt (bmpWidth, bmpHeight) _cache fptr = bitmapData
-- Get the actual section to render
(srcX, srcY, width, height) = case maybeRect of
Nothing -> (0, 0, bmpWidth, bmpHeight)
Just (Rectangle (rx, ry) (rw, rh)) -> (rx, ry, rw, rh)
-- Create a base64 PNG data URI
dataUri = unsafePerformIO $ do
withForeignPtr fptr $ \ptr -> do
-- Read pixel data
let pixelCount = bmpWidth * bmpHeight * 4
pixelData <- V.generateM pixelCount $ \i -> do
let bytePtr = castPtr ptr :: Ptr Word8
peekElemOff bytePtr i
-- Create JuicyPixels image
let img = JP.Image bmpWidth bmpHeight pixelData :: JP.Image JP.PixelRGBA8
-- Encode to PNG and then base64
let pngBytes = JP.encodePng img
pure $ "data:image/png;base64," ++ encodeBase64 (BL.unpack pngBytes)
-- Center the image at origin (Brillo convention)
halfW = fromIntegral width / 2 :: Float
halfH = fromIntegral height / 2 :: Float
in
T.concat
[ " <image x=\""
, T.pack (showFloat (-halfW))
, "\" y=\""
, T.pack (showFloat (-halfH))
, "\" width=\""
, T.pack (show width)
, "\" height=\""
, T.pack (show height)
, "\" xlink:href=\""
, T.pack dataUri
, "\""
, if srcX /= 0 || srcY /= 0
then
T.concat
[ " preserveAspectRatio=\"xMinYMin slice\" viewBox=\""
, T.pack (show srcX)
, " "
, T.pack (show srcY)
, " "
, T.pack (show width)
, " "
, T.pack (show height)
, "\""
]
else ""
, "/>\n"
]
-- | Simple base64 encoding
encodeBase64 :: [Word8] -> String
encodeBase64 = go
where
go [] = ""
go [a] =
let (i1, i2) = (fromIntegral a `div` 4, (fromIntegral a `mod` 4) * 16)
in [base64Char i1, base64Char i2, '=', '=']
go [a, b] =
let i1 = fromIntegral a `div` 4
i2 = ((fromIntegral a `mod` 4) * 16) + (fromIntegral b `div` 16)
i3 = (fromIntegral b `mod` 16) * 4
in [base64Char i1, base64Char i2, base64Char i3, '=']
go (a : b : c : rest) =
let i1 = fromIntegral a `div` 4
i2 = ((fromIntegral a `mod` 4) * 16) + (fromIntegral b `div` 16)
i3 = ((fromIntegral b `mod` 16) * 4) + (fromIntegral c `div` 64)
i4 = fromIntegral c `mod` 64
in base64Char i1 : base64Char i2 : base64Char i3 : base64Char i4 : go rest
base64Char :: Int -> Char
base64Char n
| n < 26 = toEnum (fromEnum 'A' + n)
| n < 52 = toEnum (fromEnum 'a' + n - 26)
| n < 62 = toEnum (fromEnum '0' + n - 52)
| n == 62 = '+'
| otherwise = '/'
-- | Escape XML special characters
escapeXML :: Text -> Text
escapeXML = T.concatMap escapeChar
where
escapeChar '<' = "<"
escapeChar '>' = ">"
escapeChar '&' = "&"
escapeChar '"' = """
escapeChar '\'' = "'"
escapeChar c = T.singleton c
-- | Show a floating point number with reasonable precision
showFloat :: Float -> String
showFloat f
| isNaN f = "0"
| isInfinite f = if f > 0 then "1e10" else "-1e10"
| abs f < 0.0001 = "0"
| abs (f - fromIntegral (round f :: Int)) < 0.0001 = show (round f :: Int)
| otherwise = printf "%.4f" f
-- | Show a point
showPoint :: Point -> String
showPoint (x, y) = showFloat x ++ " " ++ showFloat y
-- | Generate opacity attribute if needed
opacityAttr :: Color -> Text
opacityAttr color =
let a = colorOpacity color
in if a < 1.0
then T.pack $ printf " fill-opacity=\"%.3f\"" a
else ""
-- | Generate stroke opacity attribute if needed
strokeOpacityAttr :: Color -> Text
strokeOpacityAttr color =
let a = colorOpacity color
in if a < 1.0
then T.pack $ printf " stroke-opacity=\"%.3f\"" a
else ""