svgutils-0.1: Data/SVG/Tile.hs
-- SVGutils
-- Copyright (c) 2010, Neil Brown
--
-- All rights reserved.
--
-- Redistribution and use in source and binary forms, with or without
-- modification, are permitted provided that the following conditions are met:
--
-- * Redistributions of source code must retain the above copyright
-- notice, this list of conditions and the following disclaimer.
--
-- * Redistributions in binary form must reproduce the above
-- copyright notice, this list of conditions and the following
-- disclaimer in the documentation and/or other materials provided
-- with the distribution.
--
-- * Neither the name of Neil Brown nor the names of other
-- contributors may be used to endorse or promote products derived
-- from this software without specific prior written permission.
--
-- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-- "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-- LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-- A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-- OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-- SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-- LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-- DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-- THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-- (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-- | A module with a helper function for tiling several SVG files (which can vary
-- in size) into a group of SVG files of a specific size.
module Data.SVG.Tile (TileItem(..), tileSVGs, TileSettings(..)) where
import Control.Applicative ((<$>))
import Control.Arrow ((***), (&&&))
import Control.Monad (liftM)
import Data.Function (on)
import Data.List (groupBy, nub, sortBy)
import Data.Maybe (fromJust, fromMaybe)
import Data.Ord (comparing)
import Data.SVG.SVG
import Data.SVG.Internal.Fail
import Text.XML.Light (unqual, Element(..), Content(..), Attr(..), QName(..))
-- | The settings for tiling: the paper size, margin (same on all sides) and gap (between tiled items)
data TileSettings = TileSettings
{ tilePaperSize :: Size
, tileMargin :: MM
, tileGap :: MM
, ignoreNamespaceConflicts :: Bool
}
deriving (Show, Eq)
-- | An item to be tiled, with an SVG image for the front, and an optional SVG
-- for the back. If the two images are different sizes, the smallest size that
-- can accommodate both is used for tiling. This means that if you have a larger
-- back image, the front will have enough space left to match up with the back
-- (and vice versa).
--
-- The label is currently only used for error reporting.
data TileItem = TileItem { tileLabel :: String, tileFront :: SVG, tileBack :: Maybe SVG }
sizeAfterMargin :: TileSettings -> Size
sizeAfterMargin (TileSettings (Size pw ph) m _ _)
= Size (pw - m - m) (ph - m - m)
data RowInfo = RowInfo {_curX :: MM, _curY :: MM, _rowHeight :: MM }
getTileSize :: DPI -> TileItem -> FailM Size
getTileSize dpi t
= do frontSize <- maybeFail "SVG file has no size" $ getSVGSize dpi (tileFront t)
case tileBack t of
Nothing -> return frontSize
Just back -> do backSize <- maybeFail "SVG file has no size" $ getSVGSize dpi back
return (frontSize `maxSize` backSize)
-- Smallest size that can fit both
maxSize :: Size -> Size -> Size
maxSize (Size w h) (Size w' h') = Size (w `max` w') (h `max` h')
type Tiled = ([(QName, String)], [Content])
-- | Tiles the given items.
--
-- This function takes a list of front (and optional back) SVG images, then arranges
-- them using the given paper size, margin and gaps between items.
-- The return is a list of front images (with back images where needed).
--
-- This method is intended to be used to put multiple small SVG items onto a single
-- page for printing.
--
-- The layout algorithm is very simple. It places the first item in the top-left,
-- then attempts to fill the rest of the row with the next items in the list.
-- Once a row is full, it moves down to make more rows, until the page is full.
-- Thus, list items will always appear in the order they are given, and you can
-- potentially get some wasted space, especially if the items vary wildly in size,
-- and are not sorted by size first.
--
-- This method can fail because it cannot get the sizes of the items to tile
-- using 'getSVGSize', because there are conflicts between the namespaces of
-- the files, or because there are one or more items in the list that cannot
-- fit on a single page by themselves.
tileSVGs :: DPI -> TileSettings -> [TileItem] -> Either String [(SVG, Maybe SVG)]
tileSVGs dpi ts toTile = runFail $
do sizes <- mapM (getTileSize dpi) toTile
let make = uncurry $ makeTileSVG dpi ts
tiles <- filter nonBlank <$> tileRow (RowInfo 0 0 0) (zip sizes toTile)
mapM (liftM (make *** fmap make) . doAttrs) tiles
where
merge = mergeAttrs (ignoreNamespaceConflicts ts)
doAttrs :: (Tiled, Maybe Tiled) -> FailM (([Attr], [Content]), Maybe ([Attr], [Content]))
doAttrs (x, Nothing) = flip (,) Nothing <$> merge x
doAttrs (x, Just y) = do x' <- merge x
y' <- merge y
return (x', Just y')
tileRow :: RowInfo -> [(Size, TileItem)] -> FailM [(Tiled, Maybe Tiled)]
-- We must return a blank page when there is nothing to place, due to the
-- way that the rest of the algorithm works:
tileRow _ [] = return [(([], []), Nothing)]
tileRow ri (svg:svgs)
= case placeAcross ri svg of
Just (ri', el) -> do (es:ess) <- tileRow ri' svgs
-- We get the rest of the page (head of the list)
-- and add ourselves to that (and still include
-- all other pages)
return ((el *:* es) : ess)
Nothing -> case placeAcross (RowInfo 0 0 0) svg of
Just (ri', el) ->
do (es:ess) <- tileRow ri' svgs
-- We put the end of the previous page (a blank)
-- before adding ourselves to the next page (head of
-- the list) and include all the other pages
return ((([], []), Nothing) : (el *:* es) : ess)
Nothing -> Fail $ tileLabel (snd svg) ++ " won't fit on a sheet"
where
(*:*) :: (([a], b), Maybe ([a], b)) -> (([a], [b]), Maybe ([a], [b]))
-> (([a], [b]), Maybe ([a], [b]))
(*:*) (x, my) (xs, mys) = (x & xs, maybe mys (Just . (& fromMaybe ([],[]) mys)) my)
where
(&) (as, b) (as', bs') = (as ++ as', b : bs')
Size pw ph = sizeAfterMargin ts
gap = tileGap ts
nonBlank (xs, mys) = not (nullTile xs) || maybe False (not . nullTile) mys
where
nullTile = null . snd
-- Nothing if it won't fit on this sheet
placeAcross :: RowInfo -> (Size, TileItem) ->
Maybe (RowInfo, (([(QName, String)], Content), Maybe ([(QName, String)], Content)))
placeAcross (RowInfo ox oy rh) (Size w h, t)
| oy + h > ph -- Definitely too tall
= Nothing
-- So, not too tall:
| ox + w <= pw -- Not too wide for this row; it fits
= Just (RowInfo (ox + w + gap) oy (rh `max` h), placeFrontBack t w (ox, oy))
-- Not too tall, but is too wide for this row; try next row:
| (oy + rh + gap + h <= ph) && (w <= pw)
-- Will fit on next row
= Just (RowInfo (w + gap) (oy + rh + gap) h, placeFrontBack t w (0, oy + rh + gap))
-- Didn't fit on next row either:
| otherwise = Nothing
placeFrontBack :: TileItem -> MM -> (MM, MM) ->
(([(QName, String)], Content), Maybe ([(QName, String)], Content))
placeFrontBack t w (x, y)
= (namespaces &&& (Elem . placeAt dpi (x, y) . elContent . getSVGElement) $ tileFront t
,(namespaces &&& (Elem . placeAt dpi (flipHoriz w x, y) . elContent . getSVGElement)) <$> tileBack t
)
flipHoriz w x = pw - x - w
mergeAttrs :: Bool -> ([(QName, String)], a) -> FailM ([Attr], a)
mergeAttrs ignoreConflicts (attrs, x) = flip (,) x <$> mapM fromSingleton merged
where
merged :: [(QName, [String])]
merged = map ((fst . head) &&& (nub . map snd)) $ groupBy ((==) `on` fst) $ sortBy (comparing fst) attrs
fromSingleton :: (QName, [String]) -> FailM Attr
fromSingleton (k, [v]) = return (Attr k v)
fromSingleton (k, vs)
| ignoreConflicts = return (Attr k (head vs))
| otherwise = Fail $ concat
["Conflicting values for namespace "
,maybe "" (++ ":") (qPrefix k)
,qName k
,", values are: "
,show vs
]
makeTileSVG :: DPI -> TileSettings -> [Attr] -> [Content] -> SVG
makeTileSVG dpi (TileSettings (Size pw ph) margin _ _) attrs content
= fromJust . makeSVG $ Element (unqual "svg") (attrs ++
[unqual "version" ~> "1.0"
,unqual "width" ~> show pw
,unqual "height" ~> show ph
])
[Elem $ placeAt dpi (margin, margin) content]
Nothing
where
a ~> b = Attr a b