From 2ed13afed42e6896468ed77afe6ff84d05fcb9dc Mon Sep 17 00:00:00 2001 From: Henning Thielemann Date: Thu, 29 Aug 2024 07:09:45 +0200 Subject: [PATCH] lib: Write.Spreadsheet: support for borders like in existing HTML export cli: Commands.Balance: use for FODS export and balance and budget export to HTML --- hledger-lib/Hledger/Write/Html.hs | 47 +++++- hledger-lib/Hledger/Write/Ods.hs | 190 ++++++++++++++++------- hledger-lib/Hledger/Write/Spreadsheet.hs | 53 ++++++- hledger/Hledger/Cli/Commands/Balance.hs | 61 ++++++-- 4 files changed, 264 insertions(+), 87 deletions(-) diff --git a/hledger-lib/Hledger/Write/Html.hs b/hledger-lib/Hledger/Write/Html.hs index b748876cb..fba93f362 100644 --- a/hledger-lib/Hledger/Write/Html.hs +++ b/hledger-lib/Hledger/Write/Html.hs @@ -8,24 +8,43 @@ module Hledger.Write.Html ( printHtml, ) where +import qualified Hledger.Write.Spreadsheet as Spr import Hledger.Write.Spreadsheet (Type(..), Style(..), Emphasis(..), Cell(..)) +import qualified Data.Text as Text import qualified Lucid.Base as LucidBase import qualified Lucid +import Data.Text (Text) import Data.Foldable (for_) -printHtml :: [[Cell (Lucid.Html ())]] -> Lucid.Html () -printHtml table = +printHtml :: (Lines border) => [[Cell border (Lucid.Html ())]] -> Lucid.Html () +printHtml table = do + Lucid.style_ $ Text.unlines $ + "" : + "table {border-collapse:collapse}" : + "th, td {padding-left:1em}" : + "th.account, td.account {padding-left:0;}" : + [] Lucid.table_ $ for_ table $ \row -> - Lucid.tr_ $ for_ row $ \cell -> - formatCell cell + Lucid.tr_ $ for_ row $ \cell -> + formatCell cell -formatCell :: Cell (Lucid.Html ()) -> Lucid.Html () +formatCell :: (Lines border) => Cell border (Lucid.Html ()) -> Lucid.Html () formatCell cell = let str = cellContent cell in + let border field access = + map (field<>) $ borderLines $ access $ cellBorder cell in + let leftBorder = border "border-left:" Spr.borderLeft in + let rightBorder = border "border-right:" Spr.borderRight in + let topBorder = border "border-top:" Spr.borderTop in + let bottomBorder = border "border-bottom:" Spr.borderBottom in + let style = + case leftBorder++rightBorder++topBorder++bottomBorder of + [] -> [] + ss -> [Lucid.style_ $ Text.intercalate "; " ss] in case cellStyle cell of - Head -> Lucid.th_ str + Head -> Lucid.th_ style str Body emph -> let align = case cellType cell of @@ -36,4 +55,18 @@ formatCell cell = case emph of Item -> id Total -> Lucid.b_ - in Lucid.td_ align $ withEmph str + in Lucid.td_ (style++align) $ withEmph str + + +class (Spr.Lines border) => Lines border where + borderLines :: border -> [Text] + +instance Lines () where + borderLines () = [] + +instance Lines Spr.NumLines where + borderLines prop = + case prop of + Spr.NoLine -> [] + Spr.SingleLine -> ["black"] + Spr.DoubleLine -> ["double black"] diff --git a/hledger-lib/Hledger/Write/Ods.hs b/hledger-lib/Hledger/Write/Ods.hs index cdeb014f6..12887e1f5 100644 --- a/hledger-lib/Hledger/Write/Ods.hs +++ b/hledger-lib/Hledger/Write/Ods.hs @@ -12,6 +12,7 @@ module Hledger.Write.Ods ( printFods, ) where +import qualified Hledger.Write.Spreadsheet as Spr import Hledger.Write.Spreadsheet (Type(..), Style(..), Emphasis(..), Cell(..)) import Hledger.Data.Types (CommoditySymbol, AmountPrecision(..)) import Hledger.Data.Types (acommodity, aquantity, astyle, asprecision) @@ -20,12 +21,14 @@ import qualified Data.Text.Lazy as TL import qualified Data.Text as T import Data.Text (Text) +import qualified Data.Foldable as Fold +import qualified Data.List as List import qualified Data.Map as Map import qualified Data.Set as Set import Data.Foldable (fold) import Data.Map (Map) import Data.Set (Set) -import Data.Maybe (mapMaybe) +import Data.Maybe (catMaybes) import qualified System.IO as IO import Text.Printf (printf) @@ -33,7 +36,7 @@ import Text.Printf (printf) printFods :: IO.TextEncoding -> - Map Text ((Maybe Int, Maybe Int), [[Cell Text]]) -> TL.Text + Map Text ((Maybe Int, Maybe Int), [[Cell Spr.NumLines Text]]) -> TL.Text printFods encoding tables = let fileOpen customStyles = map (map (\c -> case c of '\'' -> '"'; _ -> c)) $ @@ -57,20 +60,6 @@ printFods encoding tables = " xmlns:field='urn:openoffice:names:experimental:ooo-ms-interop:xmlns:field:1.0'" : " xmlns:form='urn:oasis:names:tc:opendocument:xmlns:form:1.0'>" : "" : - " " : - " " : - " " : - " " : - " " : - " " : - " " : - " " : - " " : - " " : - " " : - " " : - " " : - " " : " " : " " : " -" : @@ -78,12 +67,6 @@ printFods encoding tables = " -" : " " : " " : - " " : - " " : - " " : - " " : customStyles ++ "" : [] @@ -135,7 +118,7 @@ printFods encoding tables = in TL.unlines $ map (TL.fromStrict . T.pack) $ fileOpen (let styles = cellStyles (foldMap (concat.snd) tables) in - (numberConfig =<< Set.toList (Set.map snd styles)) + (numberConfig =<< Set.toList (foldMap (numberParams.snd) styles)) ++ (cellConfig =<< Set.toList styles)) ++ tableConfig (fmap fst tables) ++ @@ -150,18 +133,23 @@ printFods encoding tables = fileClose -cellStyles :: [Cell Text] -> Set (Emphasis, (CommoditySymbol, AmountPrecision)) +dataStyleFromType :: Type -> DataStyle +dataStyleFromType typ = + case typ of + TypeString -> DataString + TypeDate -> DataDate + TypeAmount amt -> DataAmount (acommodity amt) (asprecision $ astyle amt) + TypeMixedAmount -> DataMixedAmount + +cellStyles :: + (Ord border) => + [Cell border Text] -> + Set ((Spr.Border border, Style), DataStyle) cellStyles = Set.fromList . - mapMaybe (\cell -> - case cellType cell of - TypeAmount amt -> - Just - (case cellStyle cell of - Body emph -> emph - Head -> Total, - (acommodity amt, asprecision $ astyle amt)) - _ -> Nothing) + map (\cell -> + ((cellBorder cell, cellStyle cell), + dataStyleFromType $ cellType cell)) numberStyleName :: (CommoditySymbol, AmountPrecision) -> String numberStyleName (comm, prec) = @@ -170,6 +158,10 @@ numberStyleName (comm, prec) = NaturalPrecision -> "natural" Precision k -> show k +numberParams :: DataStyle -> Set (CommoditySymbol, AmountPrecision) +numberParams (DataAmount comm prec) = Set.singleton (comm, prec) +numberParams _ = Set.empty + numberConfig :: (CommoditySymbol, AmountPrecision) -> [String] numberConfig (comm, prec) = let precStr = @@ -191,41 +183,123 @@ emphasisName emph = Item -> "item" Total -> "total" -cellConfig :: (Emphasis, (CommoditySymbol, AmountPrecision)) -> [String] -cellConfig (emph, numParam) = - let name = numberStyleName numParam in - let style :: String +cellStyleName :: Style -> String +cellStyleName style = + case style of + Head -> "head" + Body emph -> emphasisName emph + +linesName :: Spr.NumLines -> Maybe String +linesName prop = + case prop of + Spr.NoLine -> Nothing + Spr.SingleLine -> Just "single" + Spr.DoubleLine -> Just "double" + +linesStyle :: Spr.NumLines -> String +linesStyle prop = + case prop of + Spr.NoLine -> "none" + Spr.SingleLine -> "1.5pt solid #000000" + Spr.DoubleLine -> "1.5pt double-thin #000000" + +borderLabels :: Spr.Border String +borderLabels = Spr.Border "left" "right" "top" "bottom" + +borderName :: Spr.Border Spr.NumLines -> String +borderName border = + (\bs -> + case bs of + [] -> "noborder" + _ -> + ("border="++) $ List.intercalate "," $ + map (\(name,num) -> name ++ ':' : num) bs) $ + catMaybes $ Fold.toList $ + liftA2 + (\name numLines -> (,) name <$> linesName numLines) + borderLabels + border + +borderStyle :: Spr.Border Spr.NumLines -> [String] +borderStyle border = + if border == Spr.noBorder + then [] + else (:[]) $ + printf " " $ + (id :: String -> String) $ fold $ + liftA2 (printf " fo:border-%s='%s'") borderLabels $ + fmap linesStyle border + +data DataStyle = + DataString + | DataDate + | DataAmount CommoditySymbol AmountPrecision + | DataMixedAmount + deriving (Eq, Ord, Show) + +cellConfig :: ((Spr.Border Spr.NumLines, Style), DataStyle) -> [String] +cellConfig ((border, cstyle), dataStyle) = + let moreStyles = + borderStyle border + ++ + ( + case cstyle of + Body Item -> [] + Body Total -> + [" "] + Head -> + " " : + " " : + [] + ) + ++ + ( + case dataStyle of + DataMixedAmount -> + [" "] + _ -> [] + ) + cstyleName = cellStyleName cstyle + bordName = borderName border + style :: String style = - printf "style:name='%s-%s' style:data-style-name='number-%s'" - (emphasisName emph) name name in - case emph of - Item -> + case dataStyle of + DataDate -> + printf + "style:name='%s-%s-date' style:data-style-name='iso-date'" + cstyleName bordName + DataAmount comm prec -> + let name = numberStyleName (comm, prec) in + printf + "style:name='%s-%s-%s' style:data-style-name='number-%s'" + cstyleName bordName name name + _ -> printf "style:name='%s-%s'" cstyleName bordName + in + case moreStyles of + [] -> printf " " style : [] - Total -> + _ -> printf " " style : - " " : + moreStyles ++ " " : [] -formatCell :: Cell Text -> [String] +formatCell :: Cell Spr.NumLines Text -> [String] formatCell cell = let style, valueType :: String - style = - case (cellStyle cell, cellType cell) of - (Body emph, TypeAmount amt) -> tableStyle $ numberStyle emph amt - (Body Item, TypeString) -> "" - (Body Item, TypeMixedAmount) -> tableStyle "amount" - (Body Item, TypeDate) -> tableStyle "date" - (Body Total, TypeString) -> tableStyle "foot" - (Body Total, TypeMixedAmount) -> tableStyle "total-amount" - (Body Total, TypeDate) -> tableStyle "foot-date" - (Head, _) -> tableStyle "head" - numberStyle emph amt = - printf "%s-%s" - (emphasisName emph) - (numberStyleName (acommodity amt, asprecision $ astyle amt)) + style = tableStyle styleName + cstyleName = cellStyleName $ cellStyle cell + bordName = borderName $ cellBorder cell + styleName :: String + styleName = + case dataStyleFromType $ cellType cell of + DataDate -> printf "%s-%s-date" cstyleName bordName + DataAmount comm prec -> + let name = numberStyleName (comm, prec) in + printf "%s-%s-%s" cstyleName bordName name + _ -> printf "%s-%s" cstyleName bordName tableStyle = printf " table:style-name='%s'" valueType = diff --git a/hledger-lib/Hledger/Write/Spreadsheet.hs b/hledger-lib/Hledger/Write/Spreadsheet.hs index 96fb14610..8da63fddd 100644 --- a/hledger-lib/Hledger/Write/Spreadsheet.hs +++ b/hledger-lib/Hledger/Write/Spreadsheet.hs @@ -7,6 +7,10 @@ module Hledger.Write.Spreadsheet ( Style(..), Emphasis(..), Cell(..), + Border(..), + Lines(..), + NumLines(..), + noBorder, defaultCell, emptyCell, ) where @@ -27,23 +31,62 @@ data Style = Body Emphasis | Head data Emphasis = Item | Total deriving (Eq, Ord, Show) -data Cell text = + +class Lines border where noLine :: border +instance Lines () where noLine = () +instance Lines NumLines where noLine = NoLine + +{- | +The same as Tab.Properties, but has 'Eq' and 'Ord' instances. +We need those for storing 'NumLines' in 'Set's. +-} +data NumLines = NoLine | SingleLine | DoubleLine + deriving (Eq, Ord, Show) + +data Border lines = + Border { + borderLeft, borderRight, + borderTop, borderBottom :: lines + } + deriving (Eq, Ord, Show) + +instance Functor Border where + fmap f (Border left right top bottom) = + Border (f left) (f right) (f top) (f bottom) + +instance Applicative Border where + pure a = Border a a a a + Border fLeft fRight fTop fBottom <*> Border left right top bottom = + Border (fLeft left) (fRight right) (fTop top) (fBottom bottom) + +instance Foldable Border where + foldMap f (Border left right top bottom) = + f left <> f right <> f top <> f bottom + +noBorder :: (Lines border) => Border border +noBorder = pure noLine + + +data Cell border text = Cell { cellType :: Type, + cellBorder :: Border border, cellStyle :: Style, cellContent :: text } -instance Functor Cell where - fmap f (Cell typ style content) = Cell typ style $ f content +instance Functor (Cell border) where + fmap f (Cell typ border style content) = + Cell typ border style $ f content -defaultCell :: text -> Cell text +defaultCell :: (Lines border) => text -> Cell border text defaultCell text = Cell { cellType = TypeString, + cellBorder = noBorder, cellStyle = Body Item, cellContent = text } -emptyCell :: (Monoid text) => Cell text +emptyCell :: (Lines border, Monoid text) => Cell border text emptyCell = defaultCell mempty diff --git a/hledger/Hledger/Cli/Commands/Balance.hs b/hledger/Hledger/Cli/Commands/Balance.hs index 755fb6ddc..fb2d1f56d 100644 --- a/hledger/Hledger/Cli/Commands/Balance.hs +++ b/hledger/Hledger/Cli/Commands/Balance.hs @@ -550,22 +550,42 @@ renderComponent topaligned oneline opts (acctname, dep, total) (FormatField ljus ,displayColour = color_ opts } + +headerCell :: Text -> Ods.Cell Ods.NumLines Text +headerCell text = + let deflt = Ods.defaultCell text + in + deflt { + Ods.cellStyle = Ods.Head, + Ods.cellBorder = + (Ods.cellBorder deflt) {Ods.borderBottom = Ods.DoubleLine} + } + +addTotalBorders :: [[Ods.Cell border text]] -> [[Ods.Cell Ods.NumLines text]] +addTotalBorders = + zipWith + (\border -> + map (\c -> c { + Ods.cellStyle = Ods.Body Ods.Total, + Ods.cellBorder = Ods.noBorder {Ods.borderTop = border}})) + (Ods.DoubleLine : repeat Ods.NoLine) + -- | Render a single-column balance report as FODS. -balanceReportAsSpreadsheet :: ReportOpts -> BalanceReport -> [[Ods.Cell Text]] +balanceReportAsSpreadsheet :: + ReportOpts -> BalanceReport -> [[Ods.Cell Ods.NumLines Text]] balanceReportAsSpreadsheet opts (items, total) = headers : concatMap (\(a, _, _, b) -> rows a b) items ++ if no_total_ opts then [] - else map (map (\c -> c {Ods.cellStyle = Ods.Body Ods.Total})) $ - rows totalRowHeadingCsv total + else addTotalBorders $ rows totalRowHeadingCsv total where cell = Ods.defaultCell headers = - map (\content -> (cell content) {Ods.cellStyle = Ods.Head}) $ + map headerCell $ "account" : case layout_ opts of LayoutBare -> ["commodity", "balance"] _ -> ["balance"] - rows :: AccountName -> MixedAmount -> [[Ods.Cell Text]] + rows :: AccountName -> MixedAmount -> [[Ods.Cell Ods.NumLines Text]] rows name ma = case layout_ opts of LayoutBare -> map (\a -> @@ -583,7 +603,9 @@ balanceReportAsSpreadsheet opts (items, total) = | layout_ opts == LayoutBare = (False, Just $ S.toList $ maCommodities mixedAmt) | otherwise = (True, Nothing) -cellFromMixedAmount :: AmountFormat -> MixedAmount -> Ods.Cell WideBuilder +cellFromMixedAmount :: + (Ods.Lines border) => + AmountFormat -> MixedAmount -> Ods.Cell border WideBuilder cellFromMixedAmount bopts mixedAmt = (Ods.defaultCell $ showMixedAmountB bopts mixedAmt) { Ods.cellType = @@ -592,7 +614,9 @@ cellFromMixedAmount bopts mixedAmt = Nothing -> Ods.TypeMixedAmount } -cellsFromMixedAmount :: AmountFormat -> MixedAmount -> [Ods.Cell WideBuilder] +cellsFromMixedAmount :: + (Ods.Lines border) => + AmountFormat -> MixedAmount -> [Ods.Cell border WideBuilder] cellsFromMixedAmount bopts mixedAmt = map (\(str,amt) -> @@ -630,14 +654,14 @@ multiBalanceReportAsCsvHelper ishtml opts = -- Helper for CSV and ODS and HTML rendering. multiBalanceReportAsSpreadsheetHelper :: - Bool -> ReportOpts -> MultiBalanceReport -> ([[Ods.Cell Text]], [[Ods.Cell Text]]) + Bool -> ReportOpts -> MultiBalanceReport -> + ([[Ods.Cell Ods.NumLines Text]], [[Ods.Cell Ods.NumLines Text]]) multiBalanceReportAsSpreadsheetHelper ishtml opts@ReportOpts{..} (PeriodicReport colspans items tr) = - (headers : concatMap fullRowAsTexts items, - map (map (\c -> c{Ods.cellStyle = Ods.Body Ods.Total})) totalrows) + (headers : concatMap fullRowAsTexts items, addTotalBorders totalrows) where cell = Ods.defaultCell headers = - map (\content -> (cell content) {Ods.cellStyle = Ods.Head}) $ + map headerCell $ "account" : case layout_ of LayoutTidy -> ["period", "start_date", "end_date", "commodity", "value"] @@ -782,7 +806,8 @@ multiBalanceReportHtmlFootRow ropts isfirstline (hdr:cells) = -- | Render the ODS table rows for a MultiBalanceReport. -- Returns the heading row, 0 or more body rows, and the totals row if enabled. multiBalanceReportAsSpreadsheet :: - ReportOpts -> MultiBalanceReport -> ((Maybe Int, Maybe Int), [[Ods.Cell Text]]) + ReportOpts -> MultiBalanceReport -> + ((Maybe Int, Maybe Int), [[Ods.Cell Ods.NumLines Text]]) multiBalanceReportAsSpreadsheet ropts mbr = let (upper,lower) = multiBalanceReportAsSpreadsheetHelper True ropts mbr in ((Just 1, case layout_ ropts of LayoutWide _ -> Just 1; _ -> Nothing), @@ -885,7 +910,8 @@ multiBalanceRowAsTextBuilders bopts ropts colspans row = multiBalanceRowAsCellBuilders bopts ropts colspans row multiBalanceRowAsCellBuilders :: - AmountFormat -> ReportOpts -> [DateSpan] -> PeriodicReportRow a MixedAmount -> [[Ods.Cell WideBuilder]] + AmountFormat -> ReportOpts -> [DateSpan] -> + PeriodicReportRow a MixedAmount -> [[Ods.Cell Ods.NumLines WideBuilder]] multiBalanceRowAsCellBuilders bopts ReportOpts{..} colspans (PeriodicReportRow _ as rowtot rowavg) = case layout_ of LayoutWide width -> [fmap (cellFromMixedAmount bopts{displayMaxWidth=width}) allamts] @@ -1192,14 +1218,15 @@ budgetReportAsCsv ropts report = map (map Ods.cellContent) $ budgetReportAsSpreadsheet ropts report -budgetReportAsSpreadsheet :: ReportOpts -> BudgetReport -> [[Ods.Cell Text]] +budgetReportAsSpreadsheet :: + ReportOpts -> BudgetReport -> [[Ods.Cell Ods.NumLines Text]] budgetReportAsSpreadsheet ReportOpts{..} (PeriodicReport colspans items totrow) = (if transpose_ then transpose else id) $ -- heading row - (map (\content -> (cell content) {Ods.cellStyle = Ods.Head}) $ + (map headerCell $ "Account" : ["Commodity" | layout_ == LayoutBare ] ++ concatMap (\spn -> [showDateSpan spn, "budget"]) colspans @@ -1211,7 +1238,7 @@ budgetReportAsSpreadsheet concatMap (rowAsTexts prrFullName) items -- totals row - ++ map (map (\c -> c {Ods.cellStyle = Ods.Body Ods.Total})) + ++ addTotalBorders (concat [ rowAsTexts (const totalRowHeadingBudgetCsv) totrow | not no_total_ ]) where @@ -1221,7 +1248,7 @@ budgetReportAsSpreadsheet rowAsTexts :: (PeriodicReportRow a BudgetCell -> Text) -> PeriodicReportRow a BudgetCell - -> [[Ods.Cell Text]] + -> [[Ods.Cell Ods.NumLines Text]] rowAsTexts render row@(PeriodicReportRow _ as (rowtot,budgettot) (rowavg, budgetavg)) | layout_ /= LayoutBare = [cell (render row) : map showNorm vals] | otherwise =