Skip to content

Commit fa5a3d6

Browse files
committed
Introduce ANSI writer for formatted console output
The ANSI writer (-t ansi) outputs a document formatted with ANSI control sequences for reading on the console. Most Pandoc elements are supported and printed in a reasonable way, if not always ideally. This version does no detection of terminal capabilities nor does it fall back to different output styles for less-capable terminals. Some gory details: - Title blocks are formatted with modest extravagance in --standalone mode. - Strong, Emph, Underline, and Strikeout spans are all formatted accordingly using SGR codes (which will be silently ignored by terminals that don't support them). - Headings have somewhat arbitrary styles applied to them that probably need immediate improvement. - Blockquotes and all flavors of list look pretty good. - Code spans are colored magenta-on-white, which on the author's terminal looks kind of like the pinkish treatment of code spans used by many stylesheets. This probably isn't a good final decision. - Code blocks are formatted by Skylighting's formatANSI using standard writer options and included directly in the output. This has some issues; see code comments. - Links are printed with OSC 8 to create hyperlinks and colored cyan. The author's terminal automatically adds a dotted-underline to OSC 8 hyperlinks, but only colors them differently on command-mouseover. Setting an underlined style on links may be more broadly accessible. OSC 8 support is not checked for, so on terminals not supporting it or with support disabled, the link text will be colored but not do anything and the links will not be printed. - Images are displayed as their alt text. Support for the Kitty and iTerm 2 inline image protocols is planned. Supporting other terminals by using Chafa (https://hpjansson.org/chafa/) to print sixels etc would be cool too but the author would have to do some FFI stuff and it would add a dependency to Pandoc. - Tables are replaced with a useless placeholder. Table output using box-drawing characters is desired. - Subscripts and Superscripts are just parenthesized when accurate Unicode representations aren't available. Because these span types could have all kinds of semantics, there's not an obvious thing to do with them. - Simple math is translated to Pandoc inlines using existing functionality. An ambitious person could look into emulating the console-mode math output of a computer algebra system, or rendering each display math element as an image with TeX or Typst and including it, or some other thing.
1 parent d664dbe commit fa5a3d6

File tree

4 files changed

+310
-0
lines changed

4 files changed

+310
-0
lines changed

data/templates/default.ansi

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
$if(titleblock)$
2+
$titleblock$
3+
4+
$endif$
5+
$for(header-includes)$
6+
$header-includes$
7+
8+
$endfor$
9+
$for(include-before)$
10+
$include-before$
11+
12+
$endfor$
13+
$if(toc)$
14+
$table-of-contents$
15+
16+
$endif$
17+
$body$
18+
$for(include-after)$
19+
20+
$include-after$
21+
$endfor$

pandoc.cabal

+1
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,7 @@ library
632632
Text.Pandoc.Writers.OOXML,
633633
Text.Pandoc.Writers.AnnotatedTable,
634634
Text.Pandoc.Writers.BibTeX,
635+
Text.Pandoc.Writers.ANSI,
635636
Text.Pandoc.PDF,
636637
Text.Pandoc.UTF8,
637638
Text.Pandoc.Scripting,

src/Text/Pandoc/Writers.hs

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module Text.Pandoc.Writers
1919
-- * Writers: converting /from/ Pandoc format
2020
Writer(..)
2121
, writers
22+
, writeANSI
2223
, writeAsciiDoc
2324
, writeAsciiDocLegacy
2425
, writeAsciiDoctor
@@ -90,6 +91,7 @@ import qualified Text.Pandoc.Format as Format
9091
import Text.Pandoc.Options
9192
import qualified Text.Pandoc.UTF8 as UTF8
9293
import Text.Pandoc.Error
94+
import Text.Pandoc.Writers.ANSI
9395
import Text.Pandoc.Writers.AsciiDoc
9496
import Text.Pandoc.Writers.BibTeX
9597
import Text.Pandoc.Writers.ChunkedHTML
@@ -200,6 +202,7 @@ writers = [
200202
,("markua" , TextWriter writeMarkua)
201203
,("chunkedhtml" , ByteStringWriter writeChunkedHTML)
202204
,("djot" , TextWriter writeDjot)
205+
,("ansi" , TextWriter writeANSI)
203206
]
204207

205208
-- | Retrieve writer, extensions based on formatSpec (format+extensions).

src/Text/Pandoc/Writers/ANSI.hs

+285
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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

Comments
 (0)