publish-2.1.5: src/RenderDocument.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module RenderDocument
( program,
)
where
import Control.Monad (filterM, forM_, forever, void)
import Core.Data
import Core.Program
import Core.System
import Core.Text
import Data.Char (isSpace)
import qualified Data.List as List (dropWhileEnd, null)
import Data.Maybe (isJust)
import qualified Data.Text.IO as T
import Environment (Bookfile (..), Env (..))
import LatexOutputReader (parseOutputForError)
import LatexPreamble (beginning, ending, preamble)
import ParseBookfile (parseBookfile)
import System.Directory
( copyFileWithMetadata,
doesDirectoryExist,
doesFileExist,
renameFile,
)
import System.Exit (ExitCode (..))
import System.FilePath.Posix
( dropExtension,
replaceDirectory,
replaceExtension,
splitFileName,
takeBaseName,
takeExtension,
)
import System.IO (hPutStrLn)
import System.Posix.Directory (changeWorkingDirectory)
import System.Posix.Temp (mkdtemp)
import System.Posix.User (getEffectiveGroupID, getEffectiveUserID)
import Text.Megaparsec (errorBundlePretty, runParser)
import Text.Pandoc
( TopLevelDivision (TopLevelSection),
def,
pandocExtensions,
readMarkdown,
readerColumns,
readerExtensions,
runIOorExplode,
writeLaTeX,
writerTopLevelDivision,
)
import Utilities (ensureDirectory, execProcess, ifNewer, isNewer)
data Mode = Once | Cycle
data Copy = InstallPdf | NoCopyPdf
program :: Program Env ()
program = do
params <- getCommandLine
(mode, copy) <- extractMode params
event "Identify .book file"
bookfile <- extractBookFile params
case mode of
Once -> do
-- normal operation, single pass
void (renderDocument (mode, copy) bookfile)
Cycle -> do
-- use inotify to rebuild on changes
forever (renderDocument (mode, copy) bookfile >>= waitForChange)
renderDocument :: (Mode, Copy) -> FilePath -> Program Env [FilePath]
renderDocument (mode, copy) file = do
event "Read .book file"
book <- processBookFile file
event "Setup temporary directory"
setupTargetFile file
setupPreambleFile
validatePreamble book
let preambles = preamblesFrom book
let fragments = fragmentsFrom book
let trailers = trailersFrom book
event "Convert preamble fragments and begin marker to LaTeX"
mapM_ processFragment preambles
setupBeginningFile
event "Convert document fragments to LaTeX"
mapM_ processFragment fragments
event "Convert end marker and trailing fragments to LaTeX"
setupEndingFile
mapM_ processFragment trailers
event "Write intermediate LaTeX file"
produceResult
event "Render document to PDF"
catch
( do
renderPDF
case copy of
InstallPdf -> copyHere
NoCopyPdf -> return ()
)
( \(e :: ExitCode) -> case mode of
Once -> throw e
Cycle -> return ()
)
return (uniqueList file preambles fragments trailers)
--
-- Quickly reduce the fragment names to a unique list so we don't waste
-- inotify watches.
--
uniqueList :: FilePath -> [FilePath] -> [FilePath] -> [FilePath] -> [FilePath]
uniqueList file preambles fragments trailers =
let files = insertElement file (intoSet trailers <> (intoSet preambles <> intoSet fragments))
in fromSet files
extractMode :: Parameters -> Program Env (Mode, Copy)
extractMode params =
let mode = case lookupOptionFlag "watch" params of
Just False -> error "Invalid State"
Just True -> Cycle
Nothing -> Once
copy = case lookupOptionFlag "no-copy" params of
Just False -> error "Invalid State"
Just True -> NoCopyPdf
Nothing -> InstallPdf
in return (mode, copy)
{-
For the situation where the .book file is in a location other than '.'
then chdir there first, so any relative paths within _it_ are handled
properly, as are inotify watches later if they are employed.
-}
extractBookFile :: Parameters -> Program Env FilePath
extractBookFile params =
let (relative, bookfile) = case lookupArgument "bookfile" params of
Nothing -> error "invalid"
Just file -> splitFileName file
in do
debugS "relative" relative
debugS "bookfile" bookfile
probe <- liftIO $ do
changeWorkingDirectory relative
doesFileExist bookfile
case probe of
True -> return bookfile
False -> do
write ("error: specified .book file \"" <> intoRope bookfile <> "\" not found.")
throw (userError "no such file")
setupTargetFile :: FilePath -> Program Env ()
setupTargetFile file = do
env <- getApplicationState
let start = startingDirectoryFrom env
let dotfile = start ++ "/.target"
params <- getCommandLine
tmpdir <- case lookupOptionValue "temp" params of
Just dir -> do
-- Append a slash so that /tmp/booga is taken as a directory.
-- Otherwise, you end up ensuring /tmp exists.
ensureDirectory (dir ++ "/")
return dir
Nothing ->
liftIO $
catch
( do
dir' <- readFile dotfile
let dir = trim dir'
probe <- doesDirectoryExist dir
if probe
then return dir
else throw boom
)
( \(_ :: IOError) -> do
dir <- mkdtemp "/tmp/publish-"
writeFile dotfile (dir ++ "\n")
return dir
)
debugS "tmpdir" tmpdir
let master = tmpdir ++ "/" ++ base ++ ".tex"
result = tmpdir ++ "/" ++ base ++ ".pdf"
let env' =
env
{ intermediateFilenamesFrom = [],
masterFilenameFrom = master,
resultFilenameFrom = result,
tempDirectoryFrom = tmpdir
}
setApplicationState env'
where
base = takeBaseName file -- "/directory/file.ext" -> "file"
boom = userError "Temp dir no longer present"
trim :: String -> String
trim = List.dropWhileEnd isSpace
setupPreambleFile :: Program Env ()
setupPreambleFile = do
env <- getApplicationState
let tmpdir = tempDirectoryFrom env
params <- getCommandLine
first <- case lookupOptionFlag "builtin-preamble" params of
Nothing -> return []
Just True -> do
let name = "00_Preamble.latex"
let target = tmpdir ++ "/" ++ name
liftIO $ withFile target WriteMode $ \handle -> do
hWrite handle preamble
return [name]
Just _ -> invalid
let env' = env {intermediateFilenamesFrom = first}
setApplicationState env'
{-
This could do a lot more; checking to see if \documentclass is present, for
example. At present this covers the (likely common) failure mode of
specifying neither -p nor a preamble in the bookfile.
-}
validatePreamble :: Bookfile -> Program Env ()
validatePreamble book = do
params <- getCommandLine
let preambles = preamblesFrom book
let builtin = isJust (lookupOptionFlag "builtin-preamble" params)
if List.null preambles && not builtin
then do
write "error: no preamble\n"
let msg :: Rope =
[quote|
You need to either a) put the name of the file including the LaTeX
preamble for your document in the .book file between the "% publish"
and "% begin" lines, or b) specify the --builtin-preamble option on
the command-line when running this program.
|]
writeR msg
terminate 2
else return ()
setupBeginningFile :: Program Env ()
setupBeginningFile = do
env <- getApplicationState
let tmpdir = tempDirectoryFrom env
files = intermediateFilenamesFrom env
file <- do
let name = "99_Beginning.latex"
let target = tmpdir ++ "/" ++ name
liftIO $ withFile target WriteMode $ \handle -> do
hWrite handle beginning
return name
let env' = env {intermediateFilenamesFrom = file : files}
setApplicationState env'
setupEndingFile :: Program Env ()
setupEndingFile = do
env <- getApplicationState
let tmpdir = tempDirectoryFrom env
files = intermediateFilenamesFrom env
file <- do
let name = "ZZ_Ending.latex"
let target = tmpdir ++ "/" ++ name
liftIO $ withFile target WriteMode $ \handle -> do
hWrite handle ending
return name
let env' = env {intermediateFilenamesFrom = file : files}
setApplicationState env'
processBookFile :: FilePath -> Program Env Bookfile
processBookFile file = do
contents <- liftIO (readFile file)
let result = runParser parseBookfile file contents
bookfile <- case result of
Left err -> do
write (intoRope (errorBundlePretty err))
terminate 1
Right value -> return value
list1 <- filterM skipNotFound (preamblesFrom bookfile)
debugS "preambles" (length list1)
list2 <- filterM skipNotFound (fragmentsFrom bookfile)
debugS "fragments" (length list2)
list3 <- filterM skipNotFound (trailersFrom bookfile)
debugS "trailers" (length list3)
return bookfile {preamblesFrom = list1, fragmentsFrom = list2, trailersFrom = list3}
where
skipNotFound :: FilePath -> Program t Bool
skipNotFound fragment = do
probe <- liftIO (doesFileExist fragment)
case probe of
True -> return True
False -> do
write ("warning: Fragment \"" <> intoRope fragment <> "\" not found, skipping")
return False
{-
Which kind of file is it? Dispatch to the appropriate reader switching on
filename extension.
-}
processFragment :: FilePath -> Program Env ()
processFragment file = do
debugS "source" file
-- Read the fragment, process it if Markdown then run it out to LaTeX.
case takeExtension file of
".markdown" -> convertMarkdown file
".md" -> convertMarkdown file
".latex" -> passthroughLaTeX file
".tex" -> passthroughLaTeX file
".svg" -> convertImage file
_ -> passthroughImage file
{-
Convert Markdown to LaTeX. This is where we "call" Pandoc.
Default behaviour from the command line is to activate all (?) of Pandoc's
Markdown extensions, but invoking via the `readMarkdown` function with
default ReaderOptions doesn't turn any on. Using `pandocExtensions` here
appears to represent the whole set.
When output format is LaTeX, the command-line _pandoc_ tool does some
somewhat convoluted heuristics to decide whether top-level headings (ie
<H1>, ====, #) are to be considered \part, \chapter, or \section. The fact
that is not deterministic is annoying. Force the issue.
Finally, for some reason, the Markdown -> LaTeX pair strips trailing
whitespace from the block, resulting in a no paragraph boundary between
files. So gratuitously add a break.
-}
convertMarkdown :: FilePath -> Program Env ()
convertMarkdown file =
let readingOptions =
def
{ readerExtensions = pandocExtensions,
readerColumns = 75
}
writingOptions =
def
{ writerTopLevelDivision = TopLevelSection
}
in do
env <- getApplicationState
let tmpdir = tempDirectoryFrom env
file' = replaceExtension file ".latex"
target = tmpdir ++ "/" ++ file'
files = intermediateFilenamesFrom env
ensureDirectory target
ifNewer file target $ do
debugS "target" target
liftIO $ do
contents <- T.readFile file
latex <- runIOorExplode $ do
parsed <- readMarkdown readingOptions contents
writeLaTeX writingOptions parsed
withFile target WriteMode $ \handle -> do
T.hPutStrLn handle latex
T.hPutStr handle "\n"
let env' = env {intermediateFilenamesFrom = file' : files}
setApplicationState env'
{-
If a source fragment is already LaTeX, simply copy it through to
the target file.
-}
passthroughLaTeX :: FilePath -> Program Env ()
passthroughLaTeX file = do
env <- getApplicationState
let tmpdir = tempDirectoryFrom env
target = tmpdir ++ "/" ++ file
files = intermediateFilenamesFrom env
ensureDirectory target
ifNewer file target $ do
debugS "target" target
liftIO $ do
copyFileWithMetadata file target
let env' = env {intermediateFilenamesFrom = file : files}
setApplicationState env'
{-
Images in SVG format need to be converted to PDF to be able to be
included in the output as LaTeX doesn't understand SVG natively, which
is slightly shocking.
-}
convertImage :: FilePath -> Program Env ()
convertImage file = do
env <- getApplicationState
let tmpdir = tempDirectoryFrom env
basepath = dropExtension file
target = tmpdir ++ "/" ++ basepath ++ ".pdf"
buffer = tmpdir ++ "/" ++ basepath ++ "~tmp.pdf"
inkscape =
[ "inkscape",
"--export-type=pdf",
"--export-filename=" ++ buffer,
file
]
ifNewer file target $ do
debugS "target" target
(exit, out, err) <- do
ensureDirectory target
execProcess inkscape
case exit of
ExitFailure _ -> do
event "Image processing failed"
debug "stderr" (intoRope err)
debug "stdout" (intoRope out)
write ("error: Unable to convert " <> intoRope file <> " from SVG to PDF")
throw exit
ExitSuccess -> liftIO $ do
renameFile buffer target
passthroughImage :: FilePath -> Program Env ()
passthroughImage file = do
env <- getApplicationState
let tmpdir = tempDirectoryFrom env
target = tmpdir ++ "/" ++ file
ensureDirectory target
ifNewer file target $ do
debugS "target" target
liftIO $ do
copyFileWithMetadata file target
{-
Finish up by writing the intermediate "master" file.
-}
produceResult :: Program Env ()
produceResult = do
env <- getApplicationState
let master = masterFilenameFrom env
files = intermediateFilenamesFrom env
debugS "master" master
liftIO $ withFile master WriteMode $ \handle -> do
hPutStrLn handle ("\\RequirePackage{import}")
forM_ (reverse files) $ \file -> do
let (path, name) = splitFileName file
hPutStrLn handle ("\\subimport{" ++ path ++ "}{" ++ name ++ "}")
getUserID :: Program a String
getUserID = liftIO $ do
uid <- getEffectiveUserID
gid <- getEffectiveGroupID
return (show uid ++ ":" ++ show gid)
renderPDF :: Program Env ()
renderPDF = do
env <- getApplicationState
let master = masterFilenameFrom env
tmpdir = tempDirectoryFrom env
user <- getUserID
params <- getCommandLine
let command = case lookupOptionValue "docker" params of
Just image ->
[ "docker",
"run",
"--rm=true",
"--volume=" ++ tmpdir ++ ":" ++ tmpdir,
"--user=" ++ user,
image,
"latexmk"
]
Nothing ->
[ "latexmk"
]
options =
[ "-lualatex",
"-output-directory=" ++ tmpdir,
"-interaction=nonstopmode",
"-halt-on-error",
"-file-line-error",
"-cd",
master
]
latexmk = command ++ options
(exit, out, err) <- execProcess latexmk
case exit of
ExitFailure _ -> do
event "Render failed"
debug "stderr" (intoRope err)
debug "stdout" (intoRope out)
write (parseOutputForError tmpdir out)
throw exit
ExitSuccess -> return ()
copyHere :: Program Env ()
copyHere = do
env <- getApplicationState
let result = resultFilenameFrom env
start = startingDirectoryFrom env
final = replaceDirectory result start -- ie ./Book.pdf
changed <- isNewer result final
case changed of
True -> do
event "Copy resultant PDF to starting directory"
debugS "result" result
debugS "final" final
liftIO $ do
copyFileWithMetadata result final
event "Complete"
False -> do
event "Result unchanged"