djot-0.1.2.1: src/Djot/Djot.hs
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE StrictData #-}
module Djot.Djot
( renderDjot
, RenderOptions(..)
)
where
import Djot.AST
import Djot.Options (RenderOptions(..))
import Data.Char (ord, chr)
import Djot.Parse (utf8ToStr)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as B8
import qualified Data.Set as Set
import Data.Set (Set)
import qualified Data.Sequence as Seq
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import Data.List (sortOn, intersperse, transpose)
import Control.Monad
import Control.Monad.State
import qualified Data.Foldable as F
import Text.DocLayout hiding (Doc)
import qualified Text.DocLayout as Layout
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8With)
import Data.Text.Encoding.Error (lenientDecode)
import qualified Data.IntMap.Strict as IntMap
renderDjot :: RenderOptions -> Doc -> Layout.Doc Text
renderDjot opts doc = evalState
(do body <- toLayout (docBlocks doc)
refs <- gets referenceMap >>= toReferences
notes <- toNotes
pure $ body $$ refs $$ notes <> cr)
BState{ noteMap = docFootnotes doc
, noteOrder = mempty
, referenceMap = docReferences doc
, autoIds = docAutoIdentifiers doc
, afterSpace = True
, nestings = IntMap.fromList
-- anything not in this list
-- will ALWAYS get {}:
[(ord '_', 0)
,(ord '*', 0)
,(ord '~', 0)
,(ord '\'', 0)
,(ord '"', 0)
,(ord '^', 0)]
, lastBullet = Nothing
, options = opts
}
data BState =
BState { noteMap :: NoteMap
, noteOrder :: M.Map ByteString Int
, referenceMap :: ReferenceMap
, autoIds :: Set ByteString
, afterSpace :: Bool
, nestings :: IntMap.IntMap Int
, lastBullet :: Maybe Char
, options :: RenderOptions
}
toReferences :: ReferenceMap -> State BState (Layout.Doc Text)
toReferences (ReferenceMap refs) =
(<> cr) . vcat <$> mapM toReference (M.toList refs)
toReference :: (ByteString, (ByteString, Attr)) -> State BState (Layout.Doc Text)
toReference (label, (url, attr)) = do
attr' <- toLayout attr
let ref = "[" <> literal (fromUtf8 label) <> "]:" <+> literal (fromUtf8 url)
pure $ attr' $$ ref
toNotes :: State BState (Layout.Doc Text)
toNotes = do
noterefs <- gets noteOrder
allLabels <- gets (M.keys . unNoteMap . noteMap)
let sortedLabels = sortOn (`M.lookup` noterefs) allLabels
(<> cr) . vsep <$> mapM toNote sortedLabels
toNote :: ByteString -> State BState (Layout.Doc Text)
toNote label = do
notes <- gets noteMap
case lookupNote label notes of
Nothing -> pure mempty
Just bls ->
hang 4 (toNoteRef label <> ":" <> space) <$> toLayout bls
fromUtf8 :: ByteString -> Text
fromUtf8 = decodeUtf8With lenientDecode
data EscapeContext = Normal
{-# INLINE escapeDjot #-}
escapeDjot :: EscapeContext -> ByteString -> Text
escapeDjot Normal bs
| B8.any escapable bs = T.pack. go . utf8ToStr $ bs
| otherwise = fromUtf8 bs
where
escapable c = c == '[' || c == ']' || c == '<' || c == '>' ||
c == '$' || c == '!' || c == '{' || c == '}' || c == ':' ||
c == '-' || c == '^' || c == '~' ||
c == '*' || c == '_' || c == '\''|| c == '"' || c == '.' ||
c == '|' || c == '`' || c == '\\'
go [] = []
go ('$':c:cs)
| c == '`' = '\\' : '$' : c : go cs
| otherwise = '$' : go (c : cs)
go ('-':cs) =
case cs of
'-':_ -> '\\' : '-' : go cs
_ -> '-' : go cs
go ('.':cs) =
case cs of
'.':'.':_ -> '\\' : '.' : go cs
_ -> '.' : go cs
go (c:':':cs)
| c /= ']'
, case cs of
[] -> True
(' ':_) -> True
_ -> False
= (if escapable c then ('\\' :) else id) $ c : ':' : go cs
go (c:cs)
| escapable c = '\\' : c : go cs
| otherwise = c : go cs
newtype BlockAttr = BlockAttr Attr
formatAttrPart :: (ByteString, ByteString) -> Layout.Doc Text
formatAttrPart ("id",ident) = literal ("#" <> fromUtf8 ident)
formatAttrPart ("class", classes') = hsep $ map (("." <>) . literal)
$ T.words $ fromUtf8 classes'
formatAttrPart (k,v) = literal (fromUtf8 k) <> "=" <>
doubleQuotes (literal (escapeDjot Normal v))
{-# SPECIALIZE toLayout :: Blocks -> State BState (Layout.Doc Text) #-}
{-# SPECIALIZE toLayout :: Inlines -> State BState (Layout.Doc Text) #-}
{-# SPECIALIZE toLayout :: Attr -> State BState (Layout.Doc Text) #-}
class ToLayout a where
toLayout :: a -> State BState (Layout.Doc Text)
instance ToLayout Inlines where
toLayout = fmap F.fold . mapM toLayout . unMany
instance ToLayout Blocks where
toLayout = fmap F.fold . mapM toLayout . unMany
instance ToLayout Attr where
toLayout (Attr kvs)
= pure $ if isEmpty contents
then mempty
else "{" <> contents <> "}"
where
contents = hsep (map formatAttrPart kvs)
instance ToLayout BlockAttr where
toLayout (BlockAttr (Attr kvs))
= pure $ if isEmpty contents
then mempty
else hang 1 "{" (contents <> "}")
where
contents = hsep (map formatAttrPart kvs)
instance ToLayout (Node Block) where
toLayout (Node _pos attr bl) =
($$) <$> (case bl of
-- don't print an id that was generated implicitly
Heading{} -> do
autoids <- gets autoIds
let Attr as = attr
toLayout $ BlockAttr
$ Attr [(k,v) | (k,v) <- as
, not (k == "id" && v `Set.member` autoids)]
Section{} -> do
autoids <- gets autoIds
let Attr as = attr
toLayout $ BlockAttr
$ Attr [(k,v) | (k,v) <- as
, not (k == "id" &&
v `Set.member` autoids)]
_ -> toLayout (BlockAttr attr))
<*> (($$ blankline) <$> case bl of
Para ils -> toLayout ils
Heading lev ils -> do
contents <- toLayout ils
pure $ literal (T.replicate lev "#") <+> contents
Section bls -> ($$ blankline) <$> toLayout bls
ThematicBreak -> pure $ literal "* * * *"
BulletList listSpacing items -> do
lastb <- gets lastBullet
let bullet = case lastb of
Just '+' -> "-"
Just '-' -> "+"
_ -> "-"
(case listSpacing of
Tight -> vcat . map chomp
Loose -> vsep) <$>
mapM (fmap (hang 2 (bullet <> space)) . toLayout) items
OrderedList listAttr listSpacing items ->
(case listSpacing of
Tight -> vcat . map chomp
Loose -> vsep) <$>
zipWithM (toOrderedListItem listAttr)
[(orderedListStart listAttr)..]
items
DefinitionList listSpacing items ->
(case listSpacing of
Tight -> vcat . map chomp
Loose -> vsep) <$>
mapM toDefinitionListItem items
TaskList listSpacing items ->
(case listSpacing of
Tight -> vcat . map chomp
Loose -> vsep) <$>
mapM toTaskListItem items
Div bls -> do
let nestedDivs = computeDivNestingLevel bls
contents <- toLayout bls
let colons = literal (T.replicate (nestedDivs + 3) ":")
pure $ colons $$ contents $$ colons
BlockQuote bls ->
if bls == mempty
then pure ">"
else prefixed "> " <$> toLayout bls
CodeBlock lang bs -> do
let longesttickline =
case B8.lines bs of
[] -> 0
ls -> maximum $ map (B8.length . B8.takeWhile (=='`')) ls
let numticks = max 3 longesttickline
let ticks = literal $ T.replicate numticks "`"
let lang' = if lang == mempty
then mempty
else literal (fromUtf8 lang)
pure $ ticks <+> lang'
$$ literal (fromUtf8 bs)
$$ ticks
Table mbCaption rows -> do
caption <- case mbCaption of
Nothing -> pure mempty
Just (Caption bls)
-> hang 2 ("^" <> space) <$> toLayout bls
body <- toTable rows
pure $ body $+$ caption
RawBlock (Format "djot") bs ->
pure $ literal (fromUtf8 bs)
RawBlock _ _ -> pure mempty)
<* modify' (\st -> st{ afterSpace = True
-- Handle case of one bullet list right after
-- another; we need to change the bullet to
-- start a new list:
, lastBullet = case bl of
BulletList{} ->
case lastBullet st of
Just '-' -> Just '+'
Just '+' -> Just '-'
_ -> Just '-'
_ -> Nothing })
toTable :: [[Cell]] -> State BState (Layout.Doc Text)
toTable [] = pure "|--|" -- minimal empty table
toTable rows = do
let getCellContents (Cell hd al ils) = ((hd, al),) <$> toLayout ils
rowContents <- mapM (mapM getCellContents) rows
let colwidths = map (maximum . map (offset . snd))
(transpose rowContents)
let toCell width ((_,align), d) =
(case align of
AlignLeft -> lblock
AlignRight -> rblock
AlignCenter -> cblock
AlignDefault -> lblock) width d
let mkRow ds = hcat $ vfill "| " : intersperse (vfill " | ") ds ++
[vfill " |"]
let mkLines ds = hcat $ vfill "|" : intersperse (vfill "|") ds ++
[vfill "|"]
let toUnderline width ((_,al),_) = literal $
case al of
AlignLeft -> ":" <> T.replicate (width + 1) "-"
AlignRight -> T.replicate (width + 1) "-" <> ":"
AlignCenter -> ":" <> T.replicate width "-" <> ":"
AlignDefault -> T.replicate width "-"
let initialSep = case rowContents of
cells@(((BodyCell,al),_):_):_ | al /= AlignDefault
-> mkLines (zipWith toUnderline colwidths cells)
_ -> mempty
let toRow cells =
let isHeader = case cells of
((HeadCell,_),_) : _ -> True
_ -> False
in mkRow (zipWith toCell colwidths cells)
$$
if isHeader
then mkLines (zipWith toUnderline colwidths cells)
else mempty
pure $ initialSep $$ vcat (map toRow rowContents)
toDefinitionListItem :: (Inlines, Blocks) -> State BState (Layout.Doc Text)
toDefinitionListItem (term, def) = do
term' <- toLayout term
def' <- toLayout def
pure $ hang 2 (":" <> space) $ term' $+$ def'
toTaskListItem :: (TaskStatus, Blocks) -> State BState (Layout.Doc Text)
toTaskListItem (status, bls) = do
contents <- toLayout bls
let marker = case status of
Incomplete -> "- [ ]" <> space
Complete -> "- [X]" <> space
pure $ hang 2 marker contents
toOrderedListItem :: OrderedListAttributes -> Int -> Blocks
-> State BState (Layout.Doc Text)
toOrderedListItem listAttr num bs = do
contents <- toLayout bs
let marker = formatOrderedListMarker listAttr num
pure $ hang (offset marker + 1) (marker <> space) contents
formatOrderedListMarker :: OrderedListAttributes -> Int -> Layout.Doc Text
formatOrderedListMarker listAttr =
addDelims (orderedListDelim listAttr) .
formatNumber (orderedListStyle listAttr)
addDelims :: OrderedListDelim -> Layout.Doc Text -> Layout.Doc Text
addDelims RightPeriod d = d <> "."
addDelims RightParen d = d <> ")"
addDelims LeftRightParen d = "(" <> d <> ")"
formatNumber :: OrderedListStyle -> Int -> Layout.Doc Text
formatNumber Decimal n = literal (T.pack (show n))
formatNumber LetterUpper n = literal (T.singleton (chr (ord 'A' + n - 1)))
formatNumber LetterLower n = literal (T.singleton (chr (ord 'a' + n - 1)))
formatNumber RomanUpper n = literal $ toRomanNumeral n
formatNumber RomanLower n = literal $ T.toLower (toRomanNumeral n)
-- | Convert number < 4000 to uppercase roman numeral. (from pandoc)
toRomanNumeral :: Int -> T.Text
toRomanNumeral x
| x >= 4000 || x < 0 = "?"
| x >= 1000 = "M" <> toRomanNumeral (x - 1000)
| x >= 900 = "CM" <> toRomanNumeral (x - 900)
| x >= 500 = "D" <> toRomanNumeral (x - 500)
| x >= 400 = "CD" <> toRomanNumeral (x - 400)
| x >= 100 = "C" <> toRomanNumeral (x - 100)
| x >= 90 = "XC" <> toRomanNumeral (x - 90)
| x >= 50 = "L" <> toRomanNumeral (x - 50)
| x >= 40 = "XL" <> toRomanNumeral (x - 40)
| x >= 10 = "X" <> toRomanNumeral (x - 10)
| x == 9 = "IX"
| x >= 5 = "V" <> toRomanNumeral (x - 5)
| x == 4 = "IV"
| x >= 1 = "I" <> toRomanNumeral (x - 1)
| otherwise = ""
instance ToLayout (Node Inline) where
toLayout (Node _pos attr il) = (<>)
<$> case il of
Str bs -> do
let fixSmart = T.replace "\x2014" "---" .
T.replace "\x2013" "--" .
T.replace "\x2026" "..." .
T.replace "\x2019" "'" .
T.replace "\x201C" "\""
let chunks =
T.groupBy
(\c d -> (c /= ' ' && d /= ' ') || (c == ' ' && d == ' '))
(fixSmart $ escapeDjot Normal bs)
let toChunk ch
= case T.uncons ch of
Just (' ', rest)
-> afterBreak "{}" <> space <> literal rest
_ -> literal ch
pure $ hcat $ map toChunk chunks
SoftBreak -> do
opts <- gets options
pure $ if preserveSoftBreaks opts then cr else space
HardBreak -> pure (literal "\\" <> cr)
NonBreakingSpace -> pure "\\ "
Emph ils -> surround '_' ils
Strong ils -> surround '*' ils
Highlight ils -> surround '=' ils
Insert ils -> surround '+' ils
Delete ils -> surround '-' ils
Superscript ils -> surround '^' ils
Subscript ils -> surround '~' ils
Quoted SingleQuotes ils -> surround '\'' ils
Quoted DoubleQuotes ils -> surround '"' ils
Verbatim bs -> pure $ toVerbatimSpan bs
Math mt bs -> do
let suffix = toVerbatimSpan bs
let prefix = case mt of
DisplayMath -> "$$"
InlineMath -> "$"
pure $ prefix <> suffix
Symbol bs -> pure $ ":" <> literal (fromUtf8 bs) <> ":"
Span ils -> do
contents <- toLayout ils
pure $ "[" <> contents <> "]" <>
case attr of -- there must be attributes for it to be a span
Attr [] -> "{}"
_ -> mempty
Link ils target -> do
contents <- toLayout ils
let suffix = toLinkSuffix target contents
pure $ "[" <> contents <> "]" <> suffix
Image ils target -> do
contents <- toLayout ils
let suffix = toLinkSuffix target contents
pure $ "![" <> contents <> "]" <> suffix
EmailLink email -> pure $ "<" <> literal (fromUtf8 email) <> ">"
UrlLink url -> pure $ "<" <> literal (fromUtf8 url) <> ">"
RawInline (Format "djot") bs -> pure $ literal (fromUtf8 bs)
RawInline _ _ -> pure mempty
FootnoteReference label -> do
order <- gets noteOrder
case M.lookup label order of
Nothing -> modify' $ \st ->
st{ noteOrder =
M.insert label (M.size order + 1) order }
Just _ -> pure ()
pure $ toNoteRef label
<*> toLayout attr
<* modify' (\st ->
st{ afterSpace =
case il of
Str bs | isWhite (B8.takeEnd 1 bs) -> True
SoftBreak -> True
HardBreak -> True
NonBreakingSpace -> True
_ -> False })
toLinkSuffix :: Target -> Layout.Doc Text -> Layout.Doc Text
toLinkSuffix (Direct url) _ = literal $ "(" <> fromUtf8 url <> ")"
toLinkSuffix (Reference label) d
| render Nothing d == fromUtf8 label = literal "[]"
| otherwise = literal $ "[" <> fromUtf8 label <> "]"
toVerbatimSpan :: ByteString -> Layout.Doc Text
toVerbatimSpan bs =
ticks <> (if startsWithTick then " " else mempty) <>
literal (fromUtf8 bs) <>
(if endsWithTick then " " else mempty) <> ticks
where
startsWithTick = B8.take 1 bs == "`"
endsWithTick = B8.takeEnd 1 bs == "`"
ticks = literal $ T.replicate (maxticks + 1) "`"
maxticks = fst $ B8.foldl' scanTicks (0,0) bs
scanTicks (longest, theseticks) '`' =
(max (theseticks + 1) longest, theseticks + 1)
scanTicks (longest, _) _ = (longest, 0)
isWhite :: ByteString -> Bool
isWhite " " = True
isWhite "\t" = True
isWhite _ = False
surround :: Char -> Inlines -> State BState (Layout.Doc Text)
surround c ils = do
let startBeforeSpace =
case Seq.viewl (unMany ils) of
Node _pos _ (Str bs) Seq.:< _ ->
isWhite (B8.take 1 bs)
_ -> False
modify' $ \st -> st{ nestings = IntMap.adjust (+ 1) (ord c) (nestings st)}
contents <- toLayout ils
modify' $ \st -> st{ nestings = IntMap.adjust (\x -> x - 1)
(ord c) (nestings st)}
endAfterSpace <- gets afterSpace
nestingLevel <- gets (fromMaybe 1 . IntMap.lookup (ord c) . nestings)
let core = char c <> contents <> char c
pure $
if nestingLevel == 0 && not (startBeforeSpace || endAfterSpace) &&
not (null ils)
then core
else char '{' <> core <> char '}'
toNoteRef :: ByteString -> Layout.Doc Text
toNoteRef bs = literal ("[^" <> fromUtf8 bs <> "]")
computeDivNestingLevel :: Blocks -> Int
computeDivNestingLevel =
foldr go 0 . unMany
where
go (Node _pos _ (Div bls')) n =
max (n + 1) (foldr go (n + 1) (unMany bls'))
go _ n = n