|
| 1 | +{-# LANGUAGE OverloadedStrings #-} |
| 2 | +{- | |
| 3 | + Module : Text.Pandoc.Writers.ANSI |
| 4 | + Copyright : Copyright (C) 2024 Evan Silberman |
| 5 | + License : GNU GPL, version 2 or above |
| 6 | +
|
| 7 | + Maintainer : John MacFarlane <jgm@berkeley.edu> |
| 8 | + Stability : alpha |
| 9 | + Portability : portable |
| 10 | +
|
| 11 | +Conversion of 'Pandoc' documents to Ansi terminal output. |
| 12 | +-} |
| 13 | +module Text.Pandoc.Writers.ANSI ( writeANSI ) where |
| 14 | +import Control.Monad.State.Strict ( StateT, gets, modify, evalStateT ) |
| 15 | +import Data.List (intersperse) |
| 16 | +import Data.Maybe (fromMaybe) |
| 17 | +import Data.Text (Text) |
| 18 | +import Text.DocLayout ((<+>), ($$), ($+$)) |
| 19 | +import Text.DocTemplates (Context(..)) |
| 20 | +import Text.Pandoc.Class.PandocMonad (PandocMonad, report) |
| 21 | +import Text.Pandoc.Definition |
| 22 | +import Text.Pandoc.Highlighting (highlight, formatANSI) |
| 23 | +import Text.Pandoc.Logging |
| 24 | +import Text.Pandoc.Options |
| 25 | +import Text.Pandoc.Shared |
| 26 | +import Text.Pandoc.Templates (renderTemplate) |
| 27 | +import Text.Pandoc.Writers.Math(texMathToInlines) |
| 28 | +import Text.Pandoc.Writers.Shared |
| 29 | +import qualified Data.Text as T |
| 30 | +import Data.Text.Lazy (toStrict) |
| 31 | +import qualified Text.DocLayout as D |
| 32 | + |
| 33 | +fleuron :: D.HasChars a => D.Doc a |
| 34 | +fleuron = D.literal "─── ☙ ───" |
| 35 | + |
| 36 | +data WriterState = WriterState { |
| 37 | + stNotes :: [D.Doc Text] -- Footnotes |
| 38 | + , stColumns :: Int -- Width of the rendered text block |
| 39 | + , stInner :: Bool -- Are we at the document's top-level or in a nested construct? |
| 40 | + } |
| 41 | + |
| 42 | +type TW = StateT WriterState |
| 43 | + |
| 44 | +withFewerColumns :: PandocMonad m => Int -> TW m a -> TW m a |
| 45 | +withFewerColumns n a = do |
| 46 | + cols <- gets stColumns |
| 47 | + inner <- gets stInner |
| 48 | + modify $ \s -> s{stColumns = max (cols - n) 4, stInner = True} |
| 49 | + result <- a |
| 50 | + modify $ \s -> s{stColumns = cols, stInner = inner} |
| 51 | + return result |
| 52 | + |
| 53 | +-- | Convert Pandoc to ANSI |
| 54 | +writeANSI :: PandocMonad m => WriterOptions -> Pandoc -> m Text |
| 55 | +writeANSI opts document = |
| 56 | + evalStateT (pandocToANSI opts document) |
| 57 | + WriterState { stNotes = [], |
| 58 | + stColumns = (writerColumns opts), |
| 59 | + stInner = False |
| 60 | + } |
| 61 | + |
| 62 | +-- | Return ANSI-styled verison of document |
| 63 | +pandocToANSI :: PandocMonad m |
| 64 | + => WriterOptions -> Pandoc -> TW m Text |
| 65 | +pandocToANSI opts (Pandoc meta blocks) = do |
| 66 | + metadata <- metaToContext opts |
| 67 | + (blockListToANSI opts) |
| 68 | + (inlineListToANSI opts) meta |
| 69 | + width <- gets stColumns |
| 70 | + let title = titleBlock width metadata |
| 71 | + body <- blockListToANSI opts blocks |
| 72 | + notes <- gets $ reverse . stNotes |
| 73 | + let notemark x = D.literal (tshow (x :: Int) <> ".") <+> D.space |
| 74 | + let marks = take (length notes) $ map notemark [1..] |
| 75 | + let hangWidth = foldr (max . D.offset) 0 marks |
| 76 | + let notepretty | not (null notes) = D.cblock width fleuron $+$ hangMarks hangWidth marks notes |
| 77 | + | otherwise = D.empty |
| 78 | + let main = D.nest 4 $ body $+$ notepretty |
| 79 | + let context = defField "body" main |
| 80 | + $ defField "titleblock" title metadata |
| 81 | + return $ |
| 82 | + case writerTemplate opts of |
| 83 | + Nothing -> toStrict $ D.renderANSI (Just width) main |
| 84 | + Just tpl -> toStrict $ D.renderANSI (Just width) $ renderTemplate tpl context |
| 85 | + |
| 86 | +titleBlock :: Int -> Context Text -> D.Doc Text |
| 87 | +titleBlock width meta = if null most then D.empty else D.cblock width $ most $+$ fleuron |
| 88 | + where |
| 89 | + title = D.bold (fromMaybe D.empty $ getField "title" meta) |
| 90 | + subtitle = fromMaybe D.empty $ getField "subtitle" meta |
| 91 | + author = D.vcat $ fromMaybe [] $ getField "author" meta |
| 92 | + date = D.italic (fromMaybe D.empty $ getField "date" meta) |
| 93 | + most = (title $$ subtitle) $+$ author $+$ date |
| 94 | + |
| 95 | +hangMarks :: Int -> [D.Doc Text] -> [D.Doc Text] -> D.Doc Text |
| 96 | +hangMarks width markers contents = |
| 97 | + D.vsep (zipWith hangMark markers contents) where |
| 98 | + hangMark m d = D.rblock width m <+> D.nest (width + 1) d |
| 99 | + |
| 100 | +stackMarks :: [D.Doc Text] -> [D.Doc Text] -> D.Doc Text |
| 101 | +stackMarks markers contents = D.vsep (zipWith stack markers contents) |
| 102 | + where stack m d = m $$ D.nest 4 d |
| 103 | + |
| 104 | +-- | Convert Pandoc block element to ANSI |
| 105 | +blockToANSI :: PandocMonad m |
| 106 | + => WriterOptions -- ^ Options |
| 107 | + -> Block -- ^ Block element |
| 108 | + -> TW m (D.Doc Text) |
| 109 | + |
| 110 | +blockToANSI opts (Div _ bs) = blockListToANSI opts bs |
| 111 | + |
| 112 | +blockToANSI opts (Plain inlines) = inlineListToANSI opts inlines |
| 113 | + |
| 114 | +blockToANSI opts (Para inlines) = inlineListToANSI opts inlines |
| 115 | + |
| 116 | +blockToANSI opts (LineBlock lns) = blockToANSI opts $ linesToPara lns |
| 117 | + |
| 118 | +blockToANSI _ b@(RawBlock _ _) = do |
| 119 | + report $ BlockNotRendered b |
| 120 | + return D.empty |
| 121 | + |
| 122 | +blockToANSI _ HorizontalRule = return $ D.blankline $$ fleuron $$ D.blankline |
| 123 | + |
| 124 | +blockToANSI opts (Header level _ inlines) = do |
| 125 | + contents <- inlineListToANSI opts inlines |
| 126 | + inner <- gets stInner |
| 127 | + return $ header inner level contents $$ D.blankline where |
| 128 | + header False 1 = (D.flush . D.bold) |
| 129 | + header True 1 = (D.underlined . D.bold) |
| 130 | + header False 2 = ((<> D.literal " ") . D.bold) |
| 131 | + header True 2 = D.bold |
| 132 | + header _ 3 = D.italic |
| 133 | + header _ _ = id |
| 134 | + |
| 135 | +-- The approach to code blocks and highlighting here is a best-effort with |
| 136 | +-- existing tools, and can easily produce results that aren't quite right. Using |
| 137 | +-- line numbers together with certain highlight styles interacts poorly with |
| 138 | +-- the "nest" combinator being applied to the whole document. The Skylighting |
| 139 | +-- formatANSI function produces fully-rendered results; a more ambitious |
| 140 | +-- approach here could process SourceLines into a Doc Text. |
| 141 | +blockToANSI opts (CodeBlock attr str) = |
| 142 | + case writerHighlightStyle opts of |
| 143 | + Nothing -> return $ D.literal str |
| 144 | + Just s -> do |
| 145 | + let fmt o = formatANSI o s |
| 146 | + result = highlight (writerSyntaxMap opts) fmt attr str |
| 147 | + return $ case result of |
| 148 | + Left _ -> D.literal str |
| 149 | + Right f -> D.literal f |
| 150 | + |
| 151 | +blockToANSI opts (BlockQuote blocks) = do |
| 152 | + contents <- withFewerColumns 2 $ blockListToANSI opts blocks |
| 153 | + return ( D.prefixed "│ " contents $$ D.blankline) |
| 154 | + |
| 155 | +blockToANSI _ Table{} = do |
| 156 | + return $ D.literal "[TABLE]" |
| 157 | + |
| 158 | +blockToANSI opts (BulletList items) = do |
| 159 | + contents <- withFewerColumns 2 $ mapM (blockListToANSI opts) items |
| 160 | + return $ D.vsep (fmap hangMark contents) where |
| 161 | + hangMark d = D.hang 2 (D.literal "• ") d |
| 162 | + |
| 163 | +blockToANSI opts (OrderedList attribs items) = do |
| 164 | + let markers = fmap D.literal $ take (length items) $ orderedListMarkers attribs |
| 165 | + let hangWidth = foldr (max . D.offset) 0 markers |
| 166 | + contents <- withFewerColumns hangWidth $ mapM (blockListToANSI opts) items |
| 167 | + return $ hangMarks hangWidth markers contents <> D.cr |
| 168 | + |
| 169 | +blockToANSI opts (DefinitionList items) = do |
| 170 | + labels <- mapM (inlineListToANSI opts . fst) items |
| 171 | + columns <- gets stColumns |
| 172 | + let hangWidth = foldr (max . D.offset) 0 labels |
| 173 | + if hangWidth > floor (toRational columns / 10 * 3) |
| 174 | + then do |
| 175 | + contents <- withFewerColumns 4 $ mapM ((mapM (blockListToANSI opts)) . snd) items |
| 176 | + return $ stackMarks (D.bold <$> labels) (D.vsep <$> contents) <> D.cr |
| 177 | + else do |
| 178 | + contents <- withFewerColumns hangWidth $ mapM ((mapM (blockListToANSI opts)) . snd) items |
| 179 | + return $ hangMarks hangWidth (D.bold <$> labels) (D.vsep <$> contents) <> D.cr |
| 180 | + |
| 181 | +blockToANSI opts (Figure _ (Caption _ caption) body) = do |
| 182 | + let captionInlines = blocksToInlines caption |
| 183 | + captionMarkup <- if null captionInlines |
| 184 | + then return D.empty |
| 185 | + else inlineListToANSI opts (blocksToInlines caption) |
| 186 | + contents <- blockListToANSI opts body |
| 187 | + return $ captionMarkup <> contents <> D.blankline |
| 188 | + |
| 189 | +-- Auxiliary functions for lists: |
| 190 | + |
| 191 | +-- | Convert list of Pandoc block elements to ANSI |
| 192 | +blockListToANSI :: PandocMonad m |
| 193 | + => WriterOptions -- ^ Options |
| 194 | + -> [Block] -- ^ List of block elements |
| 195 | + -> TW m (D.Doc Text) |
| 196 | +blockListToANSI opts blocks = |
| 197 | + D.vsep <$> mapM (blockToANSI opts) blocks |
| 198 | + |
| 199 | +-- | Convert list of Pandoc inline elements to ANSI |
| 200 | +inlineListToANSI :: PandocMonad m |
| 201 | + => WriterOptions -> [Inline] -> TW m (D.Doc Text) |
| 202 | +inlineListToANSI opts lst = |
| 203 | + D.hcat <$> mapM (inlineToANSI opts) lst |
| 204 | + |
| 205 | +-- | Convert Pandoc inline element to ANSI |
| 206 | +inlineToANSI :: PandocMonad m => WriterOptions -> Inline -> TW m (D.Doc Text) |
| 207 | + |
| 208 | +inlineToANSI opts (Span _ lst) = |
| 209 | + inlineListToANSI opts lst |
| 210 | + |
| 211 | +inlineToANSI opts (Emph lst) = do |
| 212 | + contents <- inlineListToANSI opts lst |
| 213 | + return $ D.italic contents |
| 214 | + |
| 215 | +inlineToANSI opts (Underline lst) = do |
| 216 | + contents <- inlineListToANSI opts lst |
| 217 | + return $ D.underlined contents |
| 218 | + |
| 219 | +inlineToANSI opts (Strong lst) = do |
| 220 | + contents <- inlineListToANSI opts lst |
| 221 | + return $ D.bold contents |
| 222 | + |
| 223 | +inlineToANSI opts (Strikeout lst) = do |
| 224 | + contents <- inlineListToANSI opts lst |
| 225 | + return $ D.strikeout contents |
| 226 | + |
| 227 | +inlineToANSI opts (Superscript lst) = do |
| 228 | + case traverse toSuperscriptInline lst of |
| 229 | + Just xs -> inlineListToANSI opts xs |
| 230 | + Nothing -> inlineListToANSI opts lst >>= return . D.parens |
| 231 | + |
| 232 | +inlineToANSI opts (Subscript lst) = do |
| 233 | + case traverse toSuperscriptInline lst of |
| 234 | + Just xs -> inlineListToANSI opts xs |
| 235 | + Nothing -> inlineListToANSI opts lst >>= return . D.parens |
| 236 | + |
| 237 | +inlineToANSI opts (SmallCaps lst) = inlineListToANSI opts lst |
| 238 | + |
| 239 | +inlineToANSI opts (Quoted SingleQuote lst) = do |
| 240 | + contents <- inlineListToANSI opts lst |
| 241 | + return $ "‘" <> contents <> "’" |
| 242 | + |
| 243 | +inlineToANSI opts (Quoted DoubleQuote lst) = do |
| 244 | + contents <- inlineListToANSI opts lst |
| 245 | + return $ "“" <> contents <> "”" |
| 246 | + |
| 247 | +inlineToANSI opts (Cite _ lst) = inlineListToANSI opts lst |
| 248 | + |
| 249 | +-- Making a judgment call here that for ANSI-formatted output |
| 250 | +-- intended for reading, we want to reflow inline Code on spaces |
| 251 | +inlineToANSI _ (Code _ str) = |
| 252 | + return $ D.bg D.white $ D.fg D.magenta $ D.hcat flow |
| 253 | + where flow = intersperse D.space (D.literal <$> T.words str) |
| 254 | + |
| 255 | +inlineToANSI _ (Str str) = return $ D.literal str |
| 256 | + |
| 257 | +inlineToANSI opts (Math t str) = texMathToInlines t str >>= inlineListToANSI opts |
| 258 | + |
| 259 | +inlineToANSI _ il@RawInline{} = do |
| 260 | + report $ InlineNotRendered il |
| 261 | + return "" |
| 262 | + |
| 263 | +inlineToANSI _ LineBreak = return D.cr |
| 264 | + |
| 265 | +inlineToANSI _ SoftBreak = return D.space |
| 266 | + |
| 267 | +inlineToANSI _ Space = return D.space |
| 268 | + |
| 269 | +inlineToANSI opts (Link (_, _, _) txt (src, _)) = do |
| 270 | + label <- inlineListToANSI opts txt |
| 271 | + return $ D.fg D.cyan $ D.link src label |
| 272 | + |
| 273 | +inlineToANSI opts (Image _ alt _) = do |
| 274 | + alt' <- inlineListToANSI opts alt |
| 275 | + return $ "image: " <> alt' |
| 276 | + |
| 277 | +-- by construction, we should never be lacking in superscript characters |
| 278 | +-- for the footnote number, but we'll fall back to square brackets anyway |
| 279 | +inlineToANSI opts (Note contents) = do |
| 280 | + curNotes <- gets stNotes |
| 281 | + let newnum = tshow $ length curNotes + 1 |
| 282 | + contents' <- blockListToANSI opts contents |
| 283 | + modify $ \s -> s { stNotes = contents' : curNotes } |
| 284 | + let super = T.pack <$> (traverse toSuperscript (T.unpack newnum)) |
| 285 | + return $ D.literal $ fromMaybe ("[" <> newnum <> "]") super |
0 commit comments