packages feed

zim-to-markdown 0.3.0.3 → 0.3.1.0

raw patch · 15 files changed

+595/−95 lines, 15 filesdep ~directoryPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependency ranges changed: directory

API changes (from Hackage documentation)

+ ZimToMarkdown.Converter: convertZimPageWithAssets :: LinkContext -> Map Text Text -> ZimPage -> MarkdownDocument
+ ZimToMarkdown.Options: CopyImages :: ImageMode
+ ZimToMarkdown.Options: InvalidImageMode :: Text -> OptionsError
+ ZimToMarkdown.Options: RelativeImages :: ImageMode
+ ZimToMarkdown.Options: SymlinkImages :: ImageMode
+ ZimToMarkdown.Options: [conversionImageMode] :: ConversionRequest -> ImageMode
+ ZimToMarkdown.Options: data ImageMode
+ ZimToMarkdown.Options: instance GHC.Classes.Eq ZimToMarkdown.Options.ImageMode
+ ZimToMarkdown.Options: instance GHC.Internal.Show.Show ZimToMarkdown.Options.ImageMode
+ ZimToMarkdown.Paths: relativePathTo :: FilePath -> FilePath -> Maybe FilePath
+ ZimToMarkdown.Zim: convertZimToMarkdownWithAssets :: LinkContext -> Map Text Text -> ZimPage -> MarkdownDocument
+ ZimToMarkdown.Zim: zimImagePaths :: ZimPage -> [Text]
- ZimToMarkdown.Options: ConversionRequest :: InputPath -> Maybe OutputPath -> ConversionDirection -> ConversionRequest
+ ZimToMarkdown.Options: ConversionRequest :: InputPath -> Maybe OutputPath -> ConversionDirection -> ImageMode -> ConversionRequest

Files

CHANGELOG.md view
@@ -4,6 +4,25 @@  ## [Unreleased] +## [0.3.1.0] - 2026-10-29++### Added++- Added `--images copy|symlink|relative` for external notebook images. `copy` is the default and copies them to `_assets`; `symlink` creates absolute symbolic links in `_assets`; `relative` links straight to the original files with `..` segments.+- Added `relativePathTo` for relative targets that leave the output tree, with tests for the path helper and for the option parsing.++### Fixed++- Stripped Zim image display options such as `?width=512` from Markdown image targets.+- Handled images referenced outside the notebook with `~` or an absolute path, rewriting each link relative to its page so the images open in Markdown editors.+- Skipped verbatim blocks when scanning image targets, and stopped compounding collision suffixes for generated assets.+- Imported the shared warning stanza in every component and moved `-Werror` behind the manual `development` flag, because `cabal check` rejects an unconditional `-Werror`; CI now builds and tests with `-fdevelopment`. Fixed the warnings this exposed and raised the executable's `directory` bound to 1.3.1 for `createFileLink`.++### Changed++- The converter exposes `convertZimPageWithAssets` with the final per-page image targets, and `ZimToMarkdown.Zim` exports the image target scanner.+- Single pages reject the `symlink` and `relative` modes, and `markdown-to-zim` rejects every mode except `copy`.+ ## [0.3.0.3] - 2026-10-23  ### Changed
README.md view
@@ -21,7 +21,7 @@ - Converts a whole notebook recursively, reconstructing its folder structure. - Accepts the notebook directory or its `notebook.zim` configuration file as input. - Strips the leading Zim metadata header and normalizes heading levels to `#`, `##`, and `###`.-- Copies notebook assets so images and attachments keep working.+- Copies or links external images with `--images copy|symlink|relative`, so images and attachments keep working. - Resolves internal Zim links across the notebook. - Converts Markdown back to Zim, including notebook structure. - Supports headings, emphasis, lists, task lists, links, images, anchors, tables, blockquotes, horizontal rules, and code blocks.
app/Main.hs view
@@ -5,6 +5,7 @@ import Control.Exception (Exception, IOException, displayException, try) import Data.ByteString qualified as ByteString import Data.List (sort)+import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text@@ -13,24 +14,34 @@ import System.Directory (     copyFile,     createDirectoryIfMissing,+    createFileLink,     doesDirectoryExist,     doesFileExist,+    getHomeDirectory,     listDirectory,     makeAbsolute,     pathIsSymbolicLink,+    removeFile,  ) import System.Environment (getArgs) import System.Exit (exitFailure) import System.FilePath (+    isAbsolute,     normalise,     splitDirectories,+    takeBaseName,     takeDirectory,+    takeExtension,     takeFileName,     (</>),  ) import System.IO (stderr)+import System.IO.Error (isDoesNotExistError) -import ZimToMarkdown.Converter (convertMarkdownDocument, convertZimPage)+import ZimToMarkdown.Converter (+    convertMarkdownDocument,+    convertZimPageWithAssets,+ ) import ZimToMarkdown.Domain (     ConversionDirection (..),     MarkdownDocument (..),@@ -46,6 +57,7 @@  ) import ZimToMarkdown.Options (     ConversionRequest (..),+    ImageMode (..),     InputPath (..),     Options (..),     OutputPath (..),@@ -53,8 +65,9 @@     parseOptions,     renderOptionsError,  )-import ZimToMarkdown.Paths (relativePathFrom)+import ZimToMarkdown.Paths (relativePathFrom, relativePathTo, toPosixPath) import ZimToMarkdown.Version (versionText)+import ZimToMarkdown.Zim (zimImagePaths)  main :: IO () main = do@@ -86,7 +99,16 @@ isNotebookDefinition path = takeFileName path == "notebook.zim"  runSingleFile :: ConversionRequest -> FilePath -> IO ()-runSingleFile request inputPath = do+runSingleFile request inputPath =+    case conversionImageMode request of+        CopyImages -> runSingleFileCopy request inputPath+        SymlinkImages ->+            failWith "The symlink image mode requires a notebook input."+        RelativeImages ->+            failWith "The relative image mode requires a notebook input."++runSingleFileCopy :: ConversionRequest -> FilePath -> IO ()+runSingleFileCopy request inputPath = do     readResult <- readUtf8File inputPath     case readResult of         Left message -> failWith message@@ -97,6 +119,7 @@                     convertContent                         (conversionDirection request)                         context+                        Map.empty                         content             case conversionOutput request of                 Nothing -> TextIO.putStr rendered@@ -112,59 +135,90 @@                                 >>= either failWith pure  runNotebookRoot :: ConversionRequest -> FilePath -> IO ()-runNotebookRoot request inputRoot =-    case conversionOutput request of-        Nothing ->-            failWith-                "An output directory is required when converting a notebook."-        Just outputPath -> do-            let outputRoot = unOutputPath outputPath-            inputAbsolute <- makeAbsolute inputRoot-            outputAbsolute <- makeAbsolute outputRoot-            if isStrictlyInside inputAbsolute outputAbsolute-                then-                    failWith-                        "The output directory must not be inside the input directory."-                else do-                    discovered <- discoverFiles inputRoot-                    case discovered of-                        Left message -> failWith message-                        Right relativePaths -> do-                            let items =-                                    classifyNotebookItems-                                        (conversionDirection request)-                                        relativePaths-                                knownPages = pageRelativePaths items-                            convertNotebookItems-                                request-                                inputRoot-                                outputRoot-                                knownPages-                                items-                                >>= either failWith pure+runNotebookRoot request inputRoot+    | conversionDirection request == MarkdownToZim+        && conversionImageMode request /= CopyImages =+        failWith "The --images mode applies only when converting Zim to Markdown."+    | otherwise =+        case conversionOutput request of+            Nothing ->+                failWith+                    "An output directory is required when converting a notebook."+            Just outputPath -> do+                let outputRoot = unOutputPath outputPath+                inputAbsolute <- makeAbsolute inputRoot+                outputAbsolute <- makeAbsolute outputRoot+                if isStrictlyInside inputAbsolute outputAbsolute+                    then+                        failWith+                            "The output directory must not be inside the input directory."+                    else do+                        discovered <- discoverFiles inputRoot+                        case discovered of+                            Left message -> failWith message+                            Right relativePaths -> do+                                home <-+                                    if conversionDirection request == ZimToMarkdown+                                        then do+                                            homeResult <-+                                                try getHomeDirectory ::+                                                    IO (Either IOException FilePath)+                                            pure+                                                ( either+                                                    (Left . renderFileError "home directory")+                                                    Right+                                                    homeResult+                                                )+                                        else pure (Right "")+                                let items =+                                        classifyNotebookItems+                                            (conversionDirection request)+                                            relativePaths+                                    knownPages = pageRelativePaths items+                                convertNotebookItems+                                    request+                                    home+                                    inputRoot+                                    outputAbsolute+                                    knownPages+                                    items+                                    >>= either failWith pure  convertNotebookItems ::     ConversionRequest ->+    Either Text FilePath ->     FilePath ->     FilePath ->     Set.Set RelativePath ->     [NotebookItem] ->     IO (Either Text ())-convertNotebookItems request inputRoot outputRoot knownPages = go+convertNotebookItems request home inputRoot outputRoot knownPages items = go Map.empty items   where-    go [] = pure (Right ())-    go (item : rest) = do-        result <- processItem item+    imageMode = conversionImageMode request+    reservedOutputs =+        Set.fromList+            ( [relative | AssetItem relative <- items]+                <> [ outputRelativePath+                        (conversionDirection request)+                        relative+                   | PageItem relative <- items+                   ]+            )++    go _ [] = pure (Right ())+    go assetMap (item : rest) = do+        result <- processItem assetMap item         case result of-            Right () -> go rest             Left message -> pure (Left message)+            Right nextMap -> go nextMap rest -    processItem (PageItem sourceRelative) =-        convertPageItem sourceRelative-    processItem (AssetItem relative) =-        copyAssetItem relative+    processItem assetMap (PageItem sourceRelative) =+        convertPageItem assetMap sourceRelative+    processItem assetMap (AssetItem relative) = do+        copied <- copyAssetItem relative+        pure (assetMap <$ copied) -    convertPageItem sourceRelative = do+    convertPageItem assetMap sourceRelative = do         let sourcePath = inputRoot </> unRelativePath sourceRelative             destinationRelative =                 outputRelativePath@@ -172,29 +226,224 @@                     sourceRelative             destinationPath =                 outputRoot </> unRelativePath destinationRelative-            context = LinkContext sourceRelative knownPages         readResult <- readUtf8File sourcePath         case readResult of             Left message -> pure (Left message)             Right content -> do-                let rendered =-                        convertContent-                            (conversionDirection request)-                            context-                            content-                writeUtf8File destinationPath rendered+                let externalImages =+                        case conversionDirection request of+                            ZimToMarkdown -> zimImagePaths (ZimPage content)+                            MarkdownToZim -> []+                prepared <-+                    preparePageImages+                        imageMode+                        home+                        outputRoot+                        reservedOutputs+                        assetMap+                        destinationRelative+                        externalImages+                case prepared of+                    Left message -> pure (Left message)+                    Right (assetMap', imageTargets) -> do+                        let context =+                                LinkContext+                                    { linkCurrentPage = sourceRelative+                                    , linkKnownPages = knownPages+                                    }+                            rendered =+                                convertContent+                                    (conversionDirection request)+                                    context+                                    imageTargets+                                    content+                        written <- writeUtf8File destinationPath rendered+                        pure (assetMap' <$ written)      copyAssetItem relative = do         let sourcePath = inputRoot </> unRelativePath relative             destinationPath = outputRoot </> unRelativePath relative         copyAsset sourcePath destinationPath -convertContent :: ConversionDirection -> LinkContext -> Text -> Text-convertContent direction context content =+{- | Prepares the external images of one page. Copy and symlink modes create+the asset under @_assets@ and return per-page targets; relative mode returns a+target that points to the original file with @..@ segments. Missing sources are+left untouched.+-}+preparePageImages ::+    ImageMode ->+    Either Text FilePath ->+    FilePath ->+    Set.Set RelativePath ->+    Map.Map Text RelativePath ->+    RelativePath ->+    [Text] ->+    IO (Either Text (Map.Map Text RelativePath, Map.Map Text Text))+preparePageImages imageMode home outputRoot reserved initialAssets pageRelative =+    go (Right (initialAssets, Map.empty))+  where+    pageDirectory =+        takeDirectory (outputRoot </> unRelativePath pageRelative)+    pageOutputDirectory =+        takeDirectory (unRelativePath pageRelative)++    go ::+        Either Text (Map.Map Text RelativePath, Map.Map Text Text) ->+        [Text] ->+        IO (Either Text (Map.Map Text RelativePath, Map.Map Text Text))+    go result [] = pure result+    go (Left message) (_ : _) = pure (Left message)+    go (Right (assets, targets)) (path : rest)+        | Map.member path targets = go (Right (assets, targets)) rest+        | otherwise =+            case resolveExternalImage home path of+                Left message -> pure (Left message)+                Right Nothing -> go (Right (assets, targets)) rest+                Right (Just source) -> do+                    exists <- doesFileExist source+                    if exists+                        then case imageMode of+                            RelativeImages ->+                                relativeStep assets targets source path rest+                            CopyImages ->+                                assetStep CopyImages assets targets source path rest+                            SymlinkImages ->+                                assetStep SymlinkImages assets targets source path rest+                        else go (Right (assets, targets)) rest++    relativeStep ::+        Map.Map Text RelativePath ->+        Map.Map Text Text ->+        FilePath ->+        Text ->+        [Text] ->+        IO (Either Text (Map.Map Text RelativePath, Map.Map Text Text))+    relativeStep assets targets source path rest =+        case relativePathTo pageDirectory source of+            Nothing ->+                pure+                    ( Left+                        ( "Cannot express a relative image path from "+                            <> Text.pack pageDirectory+                            <> " to "+                            <> Text.pack source+                            <> "."+                        )+                    )+            Just target ->+                go+                    (Right (assets, Map.insert path (Text.pack target) targets))+                    rest++    assetStep ::+        ImageMode ->+        Map.Map Text RelativePath ->+        Map.Map Text Text ->+        FilePath ->+        Text ->+        [Text] ->+        IO (Either Text (Map.Map Text RelativePath, Map.Map Text Text))+    assetStep mode assets targets source path rest =+        case Map.lookup path assets of+            Just destination ->+                go+                    (Right (assets, Map.insert path (targetFor destination) targets))+                    rest+            Nothing -> do+                let used = reserved <> Set.fromList (Map.elems assets)+                    destination = chooseAssetPath used (takeFileName source)+                    destinationPath =+                        outputRoot </> unRelativePath destination+                created <-+                    case mode of+                        CopyImages -> copyAsset source destinationPath+                        SymlinkImages -> createSymlink source destinationPath+                        RelativeImages -> pure (Right ())+                case created of+                    Left message -> pure (Left message)+                    Right () ->+                        go+                            ( Right+                                ( Map.insert path destination assets+                                , Map.insert path (targetFor destination) targets+                                )+                            )+                            rest++    targetFor :: RelativePath -> Text+    targetFor destination =+        Text.pack+            ( toPosixPath+                ( relativePathFrom+                    pageOutputDirectory+                    (unRelativePath destination)+                )+            )++resolveExternalImage :: Either Text FilePath -> Text -> Either Text (Maybe FilePath)+resolveExternalImage home path+    | Just rest <- Text.stripPrefix "~/" path =+        case home of+            Left message -> Left message+            Right directory -> Right (Just (directory </> Text.unpack rest))+    | Text.isPrefixOf "~" path = Right Nothing+    | isAbsolute (Text.unpack path) = Right (Just (Text.unpack path))+    | otherwise = Right Nothing++createSymlink :: FilePath -> FilePath -> IO (Either Text ())+createSymlink source destination = do+    let directory = takeDirectory destination+    created <-+        try (createDirectoryIfMissing True directory) ::+            IO (Either IOException ())+    case created of+        Left exception -> pure (Left (renderFileError directory exception))+        Right () -> do+            removed <- removeIfPresent destination+            case removed of+                Left message -> pure (Left message)+                Right () -> do+                    linked <-+                        try (createFileLink source destination) ::+                            IO (Either IOException ())+                    pure (either (Left . renderFileError destination) Right linked)++removeIfPresent :: FilePath -> IO (Either Text ())+removeIfPresent path = do+    removed <- try (removeFile path) :: IO (Either IOException ())+    case removed of+        Left exception+            | isDoesNotExistError exception -> pure (Right ())+            | otherwise -> pure (Left (renderFileError path exception))+        Right () -> pure (Right ())++chooseAssetPath :: Set.Set RelativePath -> FilePath -> RelativePath+chooseAssetPath used name = go 2 name+  where+    go :: Int -> FilePath -> RelativePath+    go attempt candidate =+        let relative = RelativePath ("_assets" </> candidate)+         in if Set.member relative used+                then go (attempt + 1) (numbered attempt name)+                else relative+    numbered :: Int -> FilePath -> FilePath+    numbered attempt base =+        takeBaseName base+            <> "-"+            <> show attempt+            <> takeExtension base++convertContent ::+    ConversionDirection ->+    LinkContext ->+    Map.Map Text Text ->+    Text ->+    Text+convertContent direction context assets content =     case direction of         ZimToMarkdown ->             unMarkdownDocument-                (convertZimPage context (ZimPage content))+                (convertZimPageWithAssets context assets (ZimPage content))         MarkdownToZim ->             unZimPage                 (convertMarkdownDocument (MarkdownDocument content))
docs/architecture.md view
@@ -14,7 +14,7 @@ | `ZimToMarkdown.Inline` | Shared inline scanning helpers. | | `ZimToMarkdown.Table` | Shared pipe table detection and normalization. | | `ZimToMarkdown.Notebook` | Pure classification of notebook files and destination paths. |-| `ZimToMarkdown.Paths` | Pure relative path calculation. |+| `ZimToMarkdown.Paths` | Pure relative path calculation for targets inside and outside the output. | | `ZimToMarkdown.Options` | Pure command line parsing and user facing messages. | | `ZimToMarkdown.Version` | Application version sourced from the Cabal package metadata. | | `Main` | Effect boundary: filesystem discovery, reading, writing, and exit codes. |@@ -43,7 +43,7 @@  ## Notebook behavior -For a notebook, the executable discovers every file and classifies it as a page or an asset. The input can be the notebook directory or its `notebook.zim` file; the definition file itself is never copied. Pages are converted to the target extension and assets are copied unchanged. Internal links are resolved against the set of discovered pages, so links keep pointing to the converted pages. Hidden files, symbolic links, and empty directories are skipped.+For a notebook, the executable discovers every file and classifies it as a page or an asset. The input can be the notebook directory or its `notebook.zim` file; the definition file itself is never copied. Pages are converted to the target extension and assets are copied unchanged. Images that pages reference outside the notebook with `~` or an absolute path follow `--images`: `copy` copies them into `_assets`, `symlink` creates absolute symlinks there, and `relative` links to the originals with `..`. Links are rewritten relative to each page; missing images keep the cleaned path, without the display options. Internal links are resolved against the set of discovered pages, so links keep pointing to the converted pages. Hidden files, symbolic links, and empty directories in the input are skipped; the `symlink` mode creates its links in the output after discovery.  ## Evolution 
docs/cli.md view
@@ -16,6 +16,7 @@ | --- | --- | | `-d DIRECTION`, `--direction DIRECTION` | `zim-to-markdown` (default) or `markdown-to-zim`. | | `-o PATH`, `--output PATH` | Output file for a single page; output directory for a notebook. |+| `--images MODE` | External images: `copy` (default), `symlink` or `relative`; notebook conversion only. | | `-h`, `--help` | Show usage information. | | `-V`, `--version` | Show the application version. | @@ -35,11 +36,16 @@  - Page files are converted to the target extension (`.txt` to `.md`, or `.md` and `.markdown` to `.txt`). - Other files are copied unchanged, preserving their relative paths.+- Images referenced outside the notebook with `~` or an absolute path follow `--images`: `copy` stores them in `_assets/`, `symlink` stores absolute symbolic links in `_assets/`, and `relative` links to the originals with `..` segments. Links are written relative to each page; missing images keep the cleaned path, without the display options. - The root `notebook.zim` file is treated as metadata and is not copied. - Internal links are resolved using every page found in the notebook. - Hidden files and symbolic links are skipped. - Empty directories are not recreated. - The output directory must not be inside the input directory.++`symlink` and `relative` require a notebook input. `markdown-to-zim` accepts only `copy`. The generated `_assets/` directory is reserved for external images; assets inside the notebook keep their own paths.++`symlink` needs symbolic-link support: on Windows it requires Developer Mode or Administrator privileges, and backup or archive tools may dereference or skip the links. These modes read the paths written in the notebook, so only convert notebooks from a trusted source; the output can copy files from outside the notebook and the symlinks reveal absolute paths.  ```sh zim-to-markdown my-notebook --output my-notebook-markdown
docs/conversion.md view
@@ -37,6 +37,8 @@  Leading Zim metadata header lines (`Content-Type`, `Wiki-Format`, `Creation-Date`, `Modification-Date`) are removed. Heading levels are normalized per page: distinct Zim levels are compressed into consecutive Markdown levels starting at `#` and capped at `###`. A pipe table without an alignment row receives a default alignment row. +Image targets lose Zim display options such as `?width=512`, because Markdown has no equivalent and the option is not part of the file name. When converting a notebook from Zim to Markdown, an image referenced with `~` or with an absolute path follows `--images`: `copy` (the default) copies it to the `_assets` directory of the output, `symlink` creates an absolute symbolic link in `_assets`, and `relative` points at the original file with `..` segments. Links are rewritten relative to each page, so the generated images open in Markdown editors; missing files keep the cleaned target.+ Colons in internal link targets are translated to path separators, so `[[Sistema:cron]]` points to `Sistema/cron.md`. Targets that look like `user@host` are rendered as plain text instead of page links.  ## Markdown to Zim
docs/domain.md view
@@ -24,7 +24,7 @@ - `[[:page]]` targets the notebook root. - `[[+page]]` targets a child of the current page. -The resolved target is rendered relative to the current page and with the output extension, so the generated Markdown links remain valid. The label renderer passed to `renderZimLink` makes inline markup inside link labels recursive.+The resolved target is rendered relative to the current page and with the output extension, so the generated Markdown links remain valid. The notebook conversion calls `convertZimPageWithAssets`, which receives the final target for each external image; the executable chooses between copying the image to `_assets`, creating an absolute symlink there, or linking to the original with `..`, and the library only substitutes the target. The label renderer passed to `renderZimLink` makes inline markup inside link labels recursive.  ## Notebook items 
docs/versioning.md view
@@ -1,6 +1,6 @@ # Versioning -The project follows the Package Version Policy with four components: `major.minor.patch.revision`. The current version is `0.3.0.3`.+The project follows the Package Version Policy with four components: `major.minor.patch.revision`. The current version is `0.3.1.0`.  ## Source of truth 
src/ZimToMarkdown/Converter.hs view
@@ -2,16 +2,30 @@ module ZimToMarkdown.Converter (     convertMarkdownDocument,     convertZimPage,+    convertZimPageWithAssets, ) where +import Data.Map.Strict qualified as Map+import Data.Text (Text)+ import ZimToMarkdown.Domain (MarkdownDocument, ZimPage) import ZimToMarkdown.Links (LinkContext) import ZimToMarkdown.Markdown (convertMarkdownToZim)-import ZimToMarkdown.Zim (convertZimToMarkdown)+import ZimToMarkdown.Zim (convertZimToMarkdown, convertZimToMarkdownWithAssets)  -- | Converts a Zim page into a Markdown document. convertZimPage :: LinkContext -> ZimPage -> MarkdownDocument convertZimPage = convertZimToMarkdown++{- | Converts a Zim page into a Markdown document, linking images to the+targets the notebook conversion prepared for them.+-}+convertZimPageWithAssets ::+    LinkContext ->+    Map.Map Text Text ->+    ZimPage ->+    MarkdownDocument+convertZimPageWithAssets = convertZimToMarkdownWithAssets  -- | Converts a Markdown document into a Zim page. convertMarkdownDocument :: MarkdownDocument -> ZimPage
src/ZimToMarkdown/Domain.hs view
@@ -7,7 +7,6 @@ ) where  import Data.Text (Text)-import System.FilePath (FilePath)  -- | The direction in which a document is converted. data ConversionDirection
src/ZimToMarkdown/Options.hs view
@@ -1,6 +1,7 @@ -- | Command line parsing and usage messages. module ZimToMarkdown.Options (     ConversionRequest (..),+    ImageMode (..),     InputPath (..),     Options (..),     OptionsError (..),@@ -12,10 +13,16 @@  import Data.Text (Text) import Data.Text qualified as Text-import System.FilePath (FilePath)  import ZimToMarkdown.Domain (ConversionDirection (..)) +-- | How images referenced outside the notebook are handled.+data ImageMode+    = CopyImages+    | SymlinkImages+    | RelativeImages+    deriving (Eq, Show)+ -- | Path of the Zim page or notebook to read. newtype InputPath = InputPath     { unInputPath :: FilePath@@ -33,6 +40,7 @@     { conversionInput :: InputPath     , conversionOutput :: Maybe OutputPath     , conversionDirection :: ConversionDirection+    , conversionImageMode :: ImageMode     }     deriving (Eq, Show) @@ -50,6 +58,7 @@     | MissingOptionValue Text     | UnexpectedArgument Text     | InvalidDirection Text+    | InvalidImageMode Text     deriving (Eq, Show)  -- | Parses command line arguments without performing any input or output.@@ -63,6 +72,7 @@     { partialInput :: Maybe FilePath     , partialOutput :: Maybe FilePath     , partialDirection :: ConversionDirection+    , partialImageMode :: ImageMode     }  emptyOptions :: PartialOptions@@ -71,6 +81,7 @@         { partialInput = Nothing         , partialOutput = Nothing         , partialDirection = ZimToMarkdown+        , partialImageMode = CopyImages         }  parseConversion :: [String] -> Either OptionsError ConversionRequest@@ -85,11 +96,13 @@                         { conversionInput = InputPath path                         , conversionOutput = OutputPath <$> partialOutput options                         , conversionDirection = partialDirection options+                        , conversionImageMode = partialImageMode options                         }     go _ ["--output"] = Left (MissingOptionValue "--output")     go _ ["-o"] = Left (MissingOptionValue "-o")     go _ ["--direction"] = Left (MissingOptionValue "--direction")     go _ ["-d"] = Left (MissingOptionValue "-d")+    go _ ["--images"] = Left (MissingOptionValue "--images")     go options ("--output" : value : rest) =         go options{partialOutput = Just value} rest     go options ("-o" : value : rest) =@@ -98,6 +111,8 @@         setDirection options value rest     go options ("-d" : value : rest) =         setDirection options value rest+    go options ("--images" : value : rest) =+        setImageMode options value rest     go options (argument : rest)         | isOption argument = Left (UnknownOption (Text.pack argument))         | otherwise =@@ -112,6 +127,12 @@             Just direction ->                 go options{partialDirection = direction} rest +    setImageMode options value rest =+        case parseImageMode value of+            Nothing -> Left (InvalidImageMode (Text.pack value))+            Just imageMode ->+                go options{partialImageMode = imageMode} rest+     isOption ('-' : _) = True     isOption _ = False @@ -122,6 +143,14 @@         "markdown-to-zim" -> Just MarkdownToZim         _ -> Nothing +parseImageMode :: String -> Maybe ImageMode+parseImageMode value =+    case value of+        "copy" -> Just CopyImages+        "symlink" -> Just SymlinkImages+        "relative" -> Just RelativeImages+        _ -> Nothing+ -- | Renders an option parsing failure as a user facing message. renderOptionsError :: OptionsError -> Text renderOptionsError optionsError =@@ -134,6 +163,10 @@             "Invalid direction: "                 <> direction                 <> ". Use zim-to-markdown or markdown-to-zim."+        InvalidImageMode imageMode ->+            "Invalid image mode: "+                <> imageMode+                <> ". Use copy, symlink or relative."  -- | Command line usage description. helpText :: Text@@ -149,6 +182,7 @@         , "Options:"         , "  -d, --direction DIRECTION  zim-to-markdown (default) or markdown-to-zim."         , "  -o, --output PATH          Output file for a page; output directory for a notebook."+        , "      --images MODE          External images: copy (default), symlink or relative."         , "  -h, --help                 Show this help."         , "  -V, --version              Show the application version."         , ""
src/ZimToMarkdown/Paths.hs view
@@ -1,10 +1,11 @@ -- | Relative path calculations shared by link resolution and the executable. module ZimToMarkdown.Paths (     relativePathFrom,+    relativePathTo,     toPosixPath, ) where -import System.FilePath (joinPath, normalise, splitDirectories)+import System.FilePath (isAbsolute, joinPath, normalise, splitDirectories, takeDrive)  {- | Computes the path of a target relative to a base directory, including @..@ segments when the paths diverge.@@ -30,3 +31,20 @@ toPosixPath :: FilePath -> FilePath toPosixPath =     map (\character -> if character == '\\' then '/' else character)++{- | Computes a relative target that may leave the base directory, returning+Nothing when the two paths share no relative root, such as different volumes.+-}+relativePathTo :: FilePath -> FilePath -> Maybe FilePath+relativePathTo from target+    | differentDrive = Nothing+    | isAbsolute relative = Nothing+    | otherwise = Just (toPosixPath relative)+  where+    normalFrom = normalise from+    normalTarget = normalise target+    fromDrive = takeDrive normalFrom+    targetDrive = takeDrive normalTarget+    differentDrive =+        not (null fromDrive) && fromDrive /= targetDrive+    relative = normalise (relativePathFrom normalFrom normalTarget)
src/ZimToMarkdown/Zim.hs view
@@ -1,6 +1,8 @@ -- | Conversion from Zim wiki markup to Markdown. module ZimToMarkdown.Zim (     convertZimToMarkdown,+    convertZimToMarkdownWithAssets,+    zimImagePaths, ) where  import Data.Char (isAlpha, isDigit)@@ -27,23 +29,34 @@  -- | Converts a Zim page into Markdown. convertZimToMarkdown :: LinkContext -> ZimPage -> MarkdownDocument-convertZimToMarkdown context (ZimPage content) =-    let lines =+convertZimToMarkdown context =+    convertZimToMarkdownWithAssets context Map.empty++{- | Converts a Zim page into Markdown, rewriting images to the targets the+notebook conversion prepared for them.+-}+convertZimToMarkdownWithAssets ::+    LinkContext ->+    Map.Map Text Text ->+    ZimPage ->+    MarkdownDocument+convertZimToMarkdownWithAssets context assets (ZimPage content) =+    let pageLines =             Text.splitOn                 "\n"                 (stripZimHeader (normalizeNewlines content))-        headingMap = buildHeadingMap lines+        headingMap = buildHeadingMap pageLines      in MarkdownDocument             ( Text.intercalate                 "\n"-                (renderBlocks context headingMap lines)+                (renderBlocks context assets headingMap pageLines)             )  stripZimHeader :: Text -> Text stripZimHeader content =-    let lines = Text.splitOn "\n" content-        remaining = dropWhile isMetadataLine lines-     in if length remaining == length lines+    let headerLines = Text.splitOn "\n" content+        remaining = dropWhile isMetadataLine headerLines+     in if length remaining == length headerLines             then content             else Text.intercalate "\n" (dropWhile isBlank remaining) @@ -67,9 +80,9 @@ Markdown levels starting at one. -} buildHeadingMap :: [Text] -> Map.Map Int Int-buildHeadingMap lines =+buildHeadingMap pageLines =     Map.fromList-        (zip (sort (nub (collectHeadingLevels lines))) [1 ..])+        (zip (sort (nub (collectHeadingLevels pageLines))) [1 ..])  collectHeadingLevels :: [Text] -> [Int] collectHeadingLevels = go OutsideVerbatim@@ -91,8 +104,8 @@     | InsideVerbatim     deriving (Eq) -renderBlocks :: LinkContext -> Map.Map Int Int -> [Text] -> [Text]-renderBlocks context headingMap = go OutsideVerbatim+renderBlocks :: LinkContext -> Map.Map Text Text -> Map.Map Int Int -> [Text] -> [Text]+renderBlocks context assets headingMap = go OutsideVerbatim   where     go _ [] = []     go state (line : rest)@@ -104,7 +117,7 @@             let (block, remaining) = takeTableBlock (line : rest)              in normalizeTable block <> go OutsideVerbatim remaining         | otherwise =-            renderLine context headingMap line : go OutsideVerbatim rest+            renderLine context assets headingMap line : go OutsideVerbatim rest  toggleState :: BlockState -> BlockState toggleState OutsideVerbatim = InsideVerbatim@@ -113,22 +126,22 @@ isVerbatimDelimiter :: Text -> Bool isVerbatimDelimiter line = Text.strip line == "'''" -renderLine :: LinkContext -> Map.Map Int Int -> Text -> Text-renderLine context headingMap line+renderLine :: LinkContext -> Map.Map Text Text -> Map.Map Int Int -> Text -> Text+renderLine context assets headingMap line     | Text.null (Text.strip line) = line     | Just (level, content) <- parseHeading line =         let markdownLevel =                 min 3 (Map.findWithDefault level level headingMap)          in Text.replicate markdownLevel "#"                 <> " "-                <> convertInline context content+                <> convertInline context assets content     | isHorizontalRule line = "---"     | Just listLine <- parseListLine line =-        renderListLine context listLine+        renderListLine context assets listLine     | Just (depth, content) <- parseIndentedBlock line =         Text.replicate depth "> "-            <> renderLine context headingMap content-    | otherwise = convertInline context line+            <> renderLine context assets headingMap content+    | otherwise = convertInline context assets line  isHorizontalRule :: Text -> Bool isHorizontalRule line =@@ -218,11 +231,11 @@                 Just (Alphabetic, rest)         _ -> Nothing -renderListLine :: LinkContext -> ListLine -> Text-renderListLine context listLine =+renderListLine :: LinkContext -> Map.Map Text Text -> ListLine -> Text+renderListLine context assets listLine =     indentToSpaces (listIndent listLine)         <> prefix-        <> convertInline context (listContent listLine)+        <> convertInline context assets (listContent listLine)   where     prefix =         case listMarker listLine of@@ -259,8 +272,8 @@     , "^{"     ] -convertInline :: LinkContext -> Text -> Text-convertInline context = go+convertInline :: LinkContext -> Map.Map Text Text -> Text -> Text+convertInline context assets = go   where     go text =         let (plain, marker) = breakOnFirstMarker inlineMarkers text@@ -303,10 +316,10 @@             enclosed                 "[["                 "]]"-                (renderZimLink context (convertInline context))+                (renderZimLink context (convertInline context assets))                 text =             keep result-        | Just result <- enclosed "{{" "}}" renderImage text =+        | Just result <- enclosed "{{" "}}" (renderImage assets) text =             keep result         | Just result <-             enclosed "_{" "}" (\inner -> "<sub>" <> go inner <> "</sub>") text =@@ -328,10 +341,11 @@                     <> Text.replace "\"" "&quot;" name                     <> "\"></a>" -renderImage :: Text -> Text-renderImage inner =+renderImage :: Map.Map Text Text -> Text -> Text+renderImage assets inner =     let (pathPart, altPart) = Text.breakOn "|" inner-        path = Text.strip pathPart+        path = cleanImagePath pathPart+        target = Map.findWithDefault path path assets         altText =             if Text.null altPart                 then Text.pack (takeBaseName (Text.unpack path))@@ -342,5 +356,43 @@                 "!["                     <> Text.replace "]" "\\]" altText                     <> "]("-                    <> renderMarkdownPath path+                    <> renderMarkdownPath target                     <> ")"++-- | Removes the Zim display options, such as @?width=512@, from an image path.+cleanImagePath :: Text -> Text+cleanImagePath rawPath =+    let trimmed = Text.strip rawPath+     in Text.strip (Text.takeWhile (/= '?') trimmed)++{- | Lists the image paths referenced by a Zim page, without display options.+Anchors such as @{{id: intro}}@ are not images and are ignored. Verbatim+blocks are skipped, because they are copied to the output unchanged.+-}+zimImagePaths :: ZimPage -> [Text]+zimImagePaths (ZimPage content) =+    concatMap scanLine (outsideVerbatim (Text.splitOn "\n" content))+  where+    outsideVerbatim = go OutsideVerbatim+      where+        go _ [] = []+        go state (line : rest)+            | isVerbatimDelimiter line = go (toggleState state) rest+            | state == InsideVerbatim = go InsideVerbatim rest+            | otherwise = line : go OutsideVerbatim rest++    scanLine text =+        case Text.breakOn "{{" text of+            (_, rest)+                | Text.null rest -> []+                | otherwise ->+                    let afterOpening = Text.drop 2 rest+                        (inner, closing) = Text.breakOn "}}" afterOpening+                        remaining = Text.drop 2 closing+                        path = cleanImagePath (Text.takeWhile (/= '|') inner)+                     in if Text.null closing+                            then scanLine afterOpening+                            else+                                if Text.isPrefixOf "id:" (Text.strip inner) || Text.null path+                                    then scanLine remaining+                                    else path : scanLine remaining
test/Spec.hs view
@@ -2,6 +2,8 @@     main, ) where +import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe) import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text@@ -12,7 +14,11 @@ import System.Exit (exitFailure) import System.FilePath ((</>)) -import ZimToMarkdown.Converter (convertMarkdownDocument, convertZimPage)+import ZimToMarkdown.Converter (+    convertMarkdownDocument,+    convertZimPage,+    convertZimPageWithAssets,+ ) import ZimToMarkdown.Domain (     ConversionDirection (..),     MarkdownDocument (..),@@ -21,7 +27,16 @@  ) import ZimToMarkdown.Links (LinkContext (..)) import ZimToMarkdown.Notebook (outputRelativePath)+import ZimToMarkdown.Options (+    ConversionRequest (..),+    Options (..),+    OptionsError,+    parseOptions,+    renderOptionsError,+ )+import ZimToMarkdown.Paths (relativePathTo) import ZimToMarkdown.Version (versionText)+import ZimToMarkdown.Zim (zimImagePaths)  data TestCase = TestCase     { testName :: String@@ -229,6 +244,37 @@         (zimToMarkdown "{{./pic.png|A picture}}")         "![A picture](./pic.png)"     , TestCase+        "zim image width option is removed"+        (zimToMarkdown "{{./pic.png?width=512}}")+        "![pic](./pic.png)"+    , TestCase+        "zim external image uses the prepared target"+        ( zimToMarkdownWithAssets+            (Map.singleton "~/pics/pic.png" "../_assets/pic.png")+            "{{~/pics/pic.png?width=512}}"+        )+        "![pic](../_assets/pic.png)"+    , TestCase+        "zim missing image keeps the cleaned path"+        (zimToMarkdown "{{~/pics/missing.png?width=512}}")+        "![missing](~/pics/missing.png)"+    , TestCase+        "zim image targets are scanned without options or anchors"+        ( Text.intercalate+            ", "+            (zimImagePaths (ZimPage "{{~/a.png?width=1}} {{id: x}} {{ id: y }} {{b.png|alt}}"))+        )+        "~/a.png, b.png"+    , TestCase+        "zim image targets skip verbatim blocks"+        ( Text.intercalate+            ", "+            ( zimImagePaths+                (ZimPage (Text.unlines ["'''", "{{~/code.png}}", "'''", "{{~/real.png}}"]))+            )+        )+        "~/real.png"+    , TestCase         "zim anchor"         (zimToMarkdown "{{id: intro}}")         "<a id=\"intro\"></a>"@@ -412,10 +458,62 @@         "notebook markdown output path"         (Text.pack (unRelativePath (outputRelativePath MarkdownToZim (RelativePath ("notes" </> "page.md")))))         (Text.pack ("notes" </> "page.txt"))+    , TestCase+        "relative path leaves the base directory"+        ( Text.pack+            ( fromMaybe+                "none"+                ( relativePathTo+                    ("/base" </> "output" </> "pages")+                    ("/base" </> "pictures" </> "pic.png")+                )+            )+        )+        "../../pictures/pic.png"+    , TestCase+        "image mode defaults to copy"+        (imageModeResult ["page.txt"])+        "CopyImages"+    , TestCase+        "image mode symlink parses"+        (imageModeResult ["--images", "symlink", "page.txt"])+        "SymlinkImages"+    , TestCase+        "image mode relative parses"+        (imageModeResult ["--images", "relative", "page.txt"])+        "RelativeImages"+    , TestCase+        "invalid image mode is rejected"+        ( optionErrorText+            (parseOptions ["--images", "bogus", "page.txt"])+        )+        "Invalid image mode: bogus. Use copy, symlink or relative."+    , TestCase+        "missing image mode value is rejected"+        (optionErrorText (parseOptions ["page.txt", "--images"]))+        "Missing value for option: --images"     ]  zimToMarkdown :: Text -> Text zimToMarkdown = zimToMarkdownWithContext linkContext++zimToMarkdownWithAssets :: Map.Map Text Text -> Text -> Text+zimToMarkdownWithAssets assets input =+    unMarkdownDocument+        (convertZimPageWithAssets linkContext assets (ZimPage input))++imageModeResult :: [String] -> Text+imageModeResult arguments =+    case parseOptions arguments of+        Left optionsError -> renderOptionsError optionsError+        Right (Convert request) ->+            Text.pack (show (conversionImageMode request))+        Right ShowHelp -> "help"+        Right ShowVersion -> "version"++optionErrorText :: Either OptionsError Options -> Text+optionErrorText =+    either renderOptionsError (const "unexpected success")  zimToMarkdownWithContext :: LinkContext -> Text -> Text zimToMarkdownWithContext context input =
zim-to-markdown.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               zim-to-markdown-version:            0.3.0.3+version:            0.3.1.0 synopsis:           Converts Zim wiki pages to Markdown and back. description:     A command line converter between Zim wiki markup and Markdown.@@ -35,11 +35,15 @@     type:     git     location: https://github.com/Tralen/zim-to-markdown.git +flag development+    description: Treat warnings as errors during development and CI.+    default:     False+    manual:      True+ common warnings     ghc-options:         -Wall         -Wcompat-        -Werror         -Widentities         -Wincomplete-patterns         -Wincomplete-record-updates@@ -48,8 +52,11 @@         -Wmissing-home-modules         -Wpartial-fields         -Wredundant-constraints+    if flag(development)+        ghc-options: -Werror  library+    import: warnings     exposed-modules:         ZimToMarkdown.Converter         ZimToMarkdown.Domain@@ -74,13 +81,14 @@     default-extensions: OverloadedStrings  executable zim-to-markdown+    import: warnings     main-is:          Main.hs     hs-source-dirs:   app     build-depends:         base              >=4.18 && <5,         bytestring        >=0.11 && <0.13,         containers        >=0.6  && <0.9,-        directory         >=1.3  && <1.4,+        directory         >=1.3.1 && <1.4,         filepath          >=1.4  && <1.6,         text              >=2.0  && <2.2,         zim-to-markdown@@ -89,6 +97,7 @@     ghc-options:      -threaded -rtsopts -with-rtsopts=-N  test-suite zim-to-markdown-test+    import: warnings     type:             exitcode-stdio-1.0     main-is:          Spec.hs     hs-source-dirs:   test