pandoc-pyplot 2.0.0.0 → 2.0.1.0
raw patch · 6 files changed
+145/−108 lines, 6 filesdep +pandocdep −random
Dependencies added: pandoc
Dependencies removed: random
Files
- CHANGELOG.md +6/−1
- README.md +14/−10
- pandoc-pyplot.cabal +3/−3
- src/Text/Pandoc/Filter/FigureSpec.hs +57/−6
- src/Text/Pandoc/Filter/Pyplot.hs +37/−85
- test/Main.hs +28/−3
CHANGELOG.md view
@@ -2,6 +2,11 @@ pandoc-pyplot uses [Semantic Versioning](http://semver.org/spec/v2.0.0.html) +Release 2.0.1.0+---------------++* Support for Markdown formatting in figure captions, including LaTeX math.+ Release 2.0.0.0 --------------- @@ -11,7 +16,7 @@ * Removed the ability to control the filename and format directly using the `plot_target=...` attribute. * Added the ability to control the directory in which figures will be saved using the `directory=...` attribute. * Added the possibility to control the figures dots-per-inch (i.e. pixel density) with the `dpi=...` attribute.-* Added the ability to control the figure format with the `format=...` attribute. Possible values are currently `"png"`, `"svg"`, `"pdf"`, and `"jpg"`/`"jpeg"`.+* Added the ability to control the figure format with the `format=...` attribute. Possible values are currently `"png"`, `"svg"`, `"pdf"`, `"jpg"`/`"jpeg"` and `"eps"`. * The confusing `plot_alt=...` attribute has been renamed to `caption=...` for obvious reasons. * The `plot_include=...` attribute has been renamed to `include=...`. * Added the generation of a higher resolution figure for every figure `pandoc-pyplot` understands.
README.md view
@@ -1,17 +1,13 @@-# pandoc-pyplot - -[](http://hackage.haskell.org/package/pandoc-pyplot) [](http://stackage.org/nightly/package/pandoc-pyplot) [](http://stackage.org/nightly/package/pandoc-pyplot) [](https://ci.appveyor.com/project/LaurentRDC/pandoc-pyplot) +# pandoc-pyplot - A Pandoc filter to generate Matplotlib figures directly in documents -## A Pandoc filter for generating figures with Matplotlib from code directly in documents +[](http://hackage.haskell.org/package/pandoc-pyplot) [](http://stackage.org/nightly/package/pandoc-pyplot) [](http://stackage.org/nightly/package/pandoc-pyplot) [](https://ci.appveyor.com/project/LaurentRDC/pandoc-pyplot)  -Inspired by [sphinx](https://sphinxdoc.org)'s `plot_directive`, `pandoc-pyplot` helps turn Python code present in your documents to embedded Matplotlib figures. +`pandoc-pyplot` turns Python code present in your documents to embedded Matplotlib figures. ## Usage The filter recognizes code blocks with the `pyplot` class present. It will run the script in the associated code block in a Python interpreter and capture the generated Matplotlib figure. -### Basic example - Here is a basic example using the scripting `matplotlib.pyplot` API: ```markdown @@ -40,10 +36,16 @@ There are more examples in the [source repository](https://github.com/LaurentRDC/pandoc-pyplot), in the `\examples` directory. -### Link to source code +## Features -In case of an output format that supports links (e.g. HTML), the embedded image generated by `pandoc-pyplot` will be a link to the source code which was used to generate the file. Therefore, other people can see what Python code was used to create your figures. +### No wasted work +`pandoc-pyplot` minimizes work, only generating figures if it absolutely must. Therefore, you can confidently run the filter on very large documents containing dozens of figures --- like a book or a thesis --- and only the figures which have recently changed will be re-generated. + +### Link to source code and high-resolution figure + +In case of an output format that supports links (e.g. HTML), the embedded image generated by `pandoc-pyplot` will be a link to the source code which was used to generate the file. Therefore, other people can see what Python code was used to create your figures. A high resolution image will be made available in a caption link. + ### Captions You can also specify a caption for your image. This is done using the optional `caption` parameter: @@ -58,6 +60,8 @@ ``` ``` +Caption formatting is either plain text or Markdown. LaTeX-style math is also support in captions (using dollar signs $...$). + ### Including scripts If you find yourself always repeating some steps, inclusion of scripts is possible using the `include` parameter. For example, if you want all plots to have the [`ggplot`](https://matplotlib.org/tutorials/introductory/customizing.html#sphx-glr-tutorials-introductory-customizing-py) style, you can write a very short preamble `style.py` like so: @@ -100,7 +104,7 @@ ### Installers -Starting with `pandoc-pyplot` version 1.1.0.0, Windows installers are also made available thanks to [Inno Setup](http://www.jrsoftware.org/isinfo.php). +Windows installers are made available thanks to [Inno Setup](http://www.jrsoftware.org/isinfo.php). You can download them from the [release page](https://github.com/LaurentRDC/pandoc-pyplot/releases/latest). ### From Hackage/Stackage
pandoc-pyplot.cabal view
@@ -1,5 +1,5 @@ name: pandoc-pyplot -version: 2.0.0.0 +version: 2.0.1.0 cabal-version: >= 1.12 synopsis: A Pandoc filter for including figures generated from Matplotlib description: A pandoc filter for including figures generated from Matplotlib. Keep the document and Python code in the same location. Output from Matplotlib is captured and included as a figure. @@ -33,16 +33,16 @@ src ghc-options: -Wall -Wcompat build-depends: - base >=4 && <5 + base >=4 && <5 , containers , directory , filepath , hashable > 1 && < 2 + , pandoc > 2 && < 3 , pandoc-types >1.12 && <2 , temporary , text , typed-process - , random > 1 && < 2 default-language: Haskell2010 executable pandoc-pyplot
src/Text/Pandoc/Filter/FigureSpec.hs view
@@ -15,22 +15,55 @@ ( FigureSpec(..) , SaveFormat(..) , saveFormatFromString + , toImage + , sourceCodePath , figurePath - , hiresFigurePath , addPlotCapture -- for testing purposes , extension ) where +import Control.Monad (join) + import Data.Hashable (Hashable, hash, hashWithSalt) +import Data.Maybe (fromMaybe) import qualified Data.Text as T import System.FilePath (FilePath, addExtension, replaceExtension, (</>)) -import Text.Pandoc.Definition (Attr) +import Text.Pandoc.Definition +import Text.Pandoc.Builder (imageWith, link, para, fromList, toList) import Text.Pandoc.Filter.Scripting (PythonScript) +import Text.Pandoc.Class (runPure) +import Text.Pandoc.Extensions (extensionsFromList, Extension(..)) +import Text.Pandoc.Options (def, ReaderOptions(..)) +import Text.Pandoc.Readers (readMarkdown) + +readerOptions :: ReaderOptions +readerOptions = def + {readerExtensions = + extensionsFromList + [ Ext_tex_math_dollars + , Ext_superscript + , Ext_subscript + ] + } + +-- | Read a figure caption in Markdown format. +captionReader :: String -> Maybe [Inline] +captionReader t = either (const Nothing) (Just . extractFromBlocks) $ runPure $ readMarkdown' (T.pack t) + where + readMarkdown' = readMarkdown readerOptions + + extractFromBlocks (Pandoc _ blocks) = mconcat $ extractInlines <$> blocks + + extractInlines (Plain inlines) = inlines + extractInlines (Para inlines) = inlines + extractInlines (LineBlock multiinlines) = join multiinlines + extractInlines _ = [] + data SaveFormat = PNG | PDF @@ -71,6 +104,21 @@ hashWithSalt salt spec = hashWithSalt salt (caption spec, script spec, directory spec, dpi spec, blockAttrs spec) +-- | Convert a FigureSpec to a Pandoc block component +toImage :: FigureSpec -> Block +toImage spec = head . toList $ para $ imageWith attrs' target' "fig:" caption' + -- To render images as figures with captions, the target title + -- must be "fig:" + -- Janky? yes + where + attrs' = blockAttrs spec + target' = figurePath spec + srcLink = link (replaceExtension target' ".txt") mempty "Source code" + hiresLink = link (hiresFigurePath spec) mempty "high res." + captionText = fromList $ fromMaybe mempty (captionReader $ caption spec) + captionLinks = mconcat [" (", srcLink, ", ", hiresLink, ")"] + caption' = captionText <> captionLinks + -- | Determine the path a figure should have. figurePath :: FigureSpec -> FilePath figurePath spec = (directory spec </> stem spec) @@ -78,6 +126,10 @@ stem = flip addExtension ext . show . hash ext = extension . saveFormat $ spec +-- | Determine the path to the source code that generated the figure. +sourceCodePath :: FigureSpec -> FilePath +sourceCodePath = flip replaceExtension ".txt" . figurePath + -- | The path to the high-resolution figure. hiresFigurePath :: FigureSpec -> FilePath hiresFigurePath spec = flip replaceExtension (".hires" <> ext) . figurePath $ spec @@ -85,10 +137,9 @@ ext = extension . saveFormat $ spec -- | Modify a Python plotting script to save the figure to a filename. --- An additional file (with extension PNG) will also be captured. -addPlotCapture :: - FigureSpec -- ^ Path where to save the figure - -> PythonScript -- ^ Code block with added capture +-- An additional file will also be captured. +addPlotCapture :: FigureSpec -- ^ Path where to save the figure + -> PythonScript -- ^ Code block with added capture addPlotCapture spec = mconcat [ script spec
src/Text/Pandoc/Filter/Pyplot.hs view
@@ -94,80 +94,63 @@ import System.Directory (createDirectoryIfMissing, doesFileExist) -import System.FilePath (isValid, makeValid, - replaceExtension, takeDirectory) +import System.FilePath (makeValid, takeDirectory) import Text.Pandoc.Definition import Text.Pandoc.Walk (walkM) import Text.Pandoc.Filter.FigureSpec (FigureSpec (..), SaveFormat (..), addPlotCapture, - figurePath, hiresFigurePath, - saveFormatFromString) + figurePath, sourceCodePath, saveFormatFromString, + toImage) import Text.Pandoc.Filter.Scripting -- | Possible errors returned by the filter data PandocPyplotError - = ScriptError Int -- ^ Running Python script has yielded an error - | InvalidTargetError FilePath -- ^ Invalid figure path - | BlockingCallError -- ^ Python script contains a block call to 'show()' + = ScriptError Int -- ^ Running Python script has yielded an error + | BlockingCallError -- ^ Python script contains a block call to 'show()' deriving (Eq) --- | Translate filter error to an error message instance Show PandocPyplotError where - show (ScriptError exitcode) = - "Script error: plot could not be generated. Exit code " <> (show exitcode) - show (InvalidTargetError fname) = "Target filename " <> fname <> " is not valid." - show BlockingCallError = "Script contains a blocking call to show, like 'plt.show()'" + show (ScriptError exitcode) = "Script error: plot could not be generated. Exit code " <> (show exitcode) + show BlockingCallError = "Script contains a blocking call to show, like 'plt.show()'" -- | Keys that pandoc-pyplot will look for in code blocks. These are only exported for testing purposes. directoryKey, captionKey, dpiKey, includePathKey, saveFormatKey :: String -directoryKey = "directory" - -captionKey = "caption" - -dpiKey = "dpi" - +directoryKey = "directory" +captionKey = "caption" +dpiKey = "dpi" includePathKey = "include" - -saveFormatKey = "format" +saveFormatKey = "format" -- | list of all keys related to pandoc-pyplot. inclusionKeys :: [String] inclusionKeys = [directoryKey, captionKey, dpiKey, includePathKey, saveFormatKey] -- | Determine inclusion specifications from Block attributes. --- Note that the target key is required, but all other parameters are optional +-- Note that the @".pyplot"@ class is required, but all other parameters are optional parseFigureSpec :: Block -> IO (Maybe FigureSpec) parseFigureSpec (CodeBlock (id', cls, attrs) content) | "pyplot" `elem` cls = Just <$> figureSpec | otherwise = return Nothing where - attrs' = Map.fromList attrs + attrs' = Map.fromList attrs filteredAttrs = filter (\(k, _) -> k `notElem` inclusionKeys) attrs - dir = makeValid $ Map.findWithDefault "generated" directoryKey attrs' - format = fromMaybe (PNG) $ saveFormatFromString $ Map.findWithDefault "png" saveFormatKey attrs' - includePath = Map.lookup includePathKey attrs' + dir = makeValid $ Map.findWithDefault "generated" directoryKey attrs' + format = fromMaybe (PNG) $ saveFormatFromString $ Map.findWithDefault "png" saveFormatKey attrs' + includePath = Map.lookup includePathKey attrs' + figureSpec :: IO FigureSpec figureSpec = do - includeScript <- fromMaybe (return "") $ T.readFile <$> includePath - let header = "# Generated by pandoc-pyplot " <> ((T.pack . showVersion) version) - fullScript = mconcat $ intersperse "\n" [header, includeScript, T.pack content] - caption' = Map.findWithDefault mempty captionKey attrs' - dpi' = read $ Map.findWithDefault "80" dpiKey attrs' + includeScript <- fromMaybe (return mempty) $ T.readFile <$> includePath + let header = "# Generated by pandoc-pyplot " <> ((T.pack . showVersion) version) + fullScript = mconcat $ intersperse "\n" [header, includeScript, T.pack content] + caption' = Map.findWithDefault mempty captionKey attrs' + dpi' = read $ Map.findWithDefault "80" dpiKey attrs' blockAttrs' = (id', filter (/= "pyplot") cls, filteredAttrs) return $ FigureSpec caption' fullScript format dir dpi' blockAttrs' -parseFigureSpec _ = return Nothing --- | Check figure specifications for common mistakes -validateSpec :: FigureSpec -> Maybe PandocPyplotError -validateSpec spec - | not (isValid path) = Just $ InvalidTargetError path - | hasBlockingShowCall rendered = Just $ BlockingCallError - | otherwise = Nothing - where - path = figurePath spec - rendered = script spec +parseFigureSpec _ = return Nothing -- | Run the Python script. In case the file already exists, we can safely assume -- there is no need to re-run it. @@ -175,59 +158,28 @@ runScriptIfNecessary spec = do createDirectoryIfMissing True . takeDirectory $ figurePath spec fileAlreadyExists <- doesFileExist $ figurePath spec - if fileAlreadyExists - then return ScriptSuccess - else do - result <- runTempPythonScript $ addPlotCapture spec - case result of - ScriptFailure code -> return $ ScriptFailure code - ScriptSuccess - -- Save the original script into a separate file - -- so it can be inspected - -- Note : using a .txt file allows to view source directly - -- in the browser, in the case of HTML output - -> do - let sourcePath = replaceExtension (figurePath spec) ".txt" - T.writeFile sourcePath $ script spec - return ScriptSuccess + result <- if fileAlreadyExists + then return ScriptSuccess + else runTempPythonScript $ addPlotCapture spec + case result of + ScriptFailure code -> return $ ScriptFailure code + ScriptSuccess -> T.writeFile (sourceCodePath spec) (script spec) >> return ScriptSuccess -- | Main routine to include Matplotlib plots. -- Code blocks containing the attributes @.pyplot@ are considered -- Python plotting scripts. All other possible blocks are ignored. makePlot' :: Block -> IO (Either PandocPyplotError Block) makePlot' block = do - parsed <- parseFigureSpec block + parsed <- parseFigureSpec block case parsed of - Nothing -> return $ Right block + Nothing -> return $ Right block Just spec -> - case validateSpec spec of - Just err -> return $ Left err - Nothing -> do - result <- runScriptIfNecessary spec - case result of - ScriptFailure code -> return $ Left $ ScriptError code - ScriptSuccess -> do - let relevantAttrs = blockAttrs spec - sourcePath = replaceExtension (figurePath spec) ".txt" - hiresPath = hiresFigurePath spec - srcTarget = Link nullAttr [Str "Source code"] (sourcePath, "") - hiresTarget = Link nullAttr [Str "high res."] (hiresPath, "") - -- TODO: use pandoc-types Builder module - caption' = - [ Str $ caption spec - , Space - , Str "(" - , srcTarget - , Str "," - , Space - , hiresTarget - , Str ")" - ] - -- To render images as figures with captions, the target title - -- must be "fig:" - -- Janky? yes - image = Image relevantAttrs caption' (figurePath spec, "fig:") - return $ Right $ Para $ [image] + if hasBlockingShowCall (script spec) + then return $ Left BlockingCallError + else handleResult spec <$> runScriptIfNecessary spec + where + handleResult _ (ScriptFailure code) = Left $ ScriptError code + handleResult spec ScriptSuccess = Right $ toImage spec -- | Highest-level function that can be walked over a Pandoc tree. -- All code blocks that have the '.pyplot' parameter will be considered
test/Main.hs view
@@ -14,7 +14,10 @@ import qualified Text.Pandoc.Filter.FigureSpec as P import qualified Text.Pandoc.Filter.Pyplot as P import qualified Text.Pandoc.Filter.Scripting as P + import Text.Pandoc.JSON +import qualified Text.Pandoc.Builder as B +import qualified Text.Pandoc.Definition as B import System.Directory (createDirectory, createDirectoryIfMissing, @@ -30,7 +33,7 @@ defaultMain $ testGroup "Text.Pandoc.Filter.Pyplot" - [testFileCreation, testFileInclusion, testSaveFormat, testBlockingCallError] + [testFileCreation, testFileInclusion, testSaveFormat, testBlockingCallError, testMarkdownFormattingCaption] plotCodeBlock :: P.PythonScript -> Block plotCodeBlock script = CodeBlock (mempty, ["pyplot"], mempty) (unpack script) @@ -126,12 +129,12 @@ assertEqual "" numberjpgFiles 2 ------------------------------------------------------------------------------- --- Test that a script containing a blockign call to matplotlib.pyplot.show +-- Test that a script containing a blocking call to matplotlib.pyplot.show -- returns the appropriate error testBlockingCallError :: TestTree testBlockingCallError = testCase "raises an exception for blocking calls" $ do - tempDir <- getCanonicalTemporaryDirectory + tempDir <- (</> "test-blocking-call-error") <$>getCanonicalTemporaryDirectory let codeBlock = plotCodeBlock "import matplotlib.pyplot as plt\nplt.show()" result <- P.makePlot' codeBlock case result of @@ -140,4 +143,26 @@ if error == P.BlockingCallError then pure () else assertFailure "did not catch the expected blocking call" +------------------------------------------------------------------------------- + +------------------------------------------------------------------------------- +-- Test that Markdown formatting in captions is correctly rendered +testMarkdownFormattingCaption :: TestTree +testMarkdownFormattingCaption = + testCase "appropriately parses Markdown captions" $ do + tempDir <- (</> "test-caption-parsing") <$> getCanonicalTemporaryDirectory + -- Note that this test is fragile, in the sense that the expected result must be carefully + -- constructed + let expected = [B.Strong [B.Str "caption"]] + codeBlock = addDirectory tempDir $ addCaption "**caption**" $ plotCodeBlock "import matplotlib.pyplot as plt" + result <- P.makePlot' codeBlock + case result of + Left error -> assertFailure $ "an error occured: " <> show error + Right block -> assertIsInfix expected (extractCaption block) + where + extractCaption (B.Para blocks) = extractImageCaption . head $ blocks + extractCaption _ = mempty + + extractImageCaption (Image _ c _) = c + extractImageCaption _ = mempty -------------------------------------------------------------------------------