diff --git a/doc/xml.md b/doc/xml.md new file mode 100644 index 000000000..df90b7eea --- /dev/null +++ b/doc/xml.md @@ -0,0 +1,376 @@ +--- +title: XML +author: massifrg@gmail.com +--- + +# Pandoc XML format + +This document describes Pandoc's `xml` format, a 1:1 equivalent +of the `native` and `json` formats. + +Here's the xml version of the beginning of this document, +to give you a glimpse of the format: + +```xml + + + + + massifrg@gmail.com + + + XML + + + +
Pandoc XML format
+ This document describes Pandoc’s xml format, a 1:1 equivalentof the native and json formats. + ... +
+
+``` + +## The tags + +If you know [Pandoc types](https://hackage.haskell.org/package/pandoc-types-1.23.1/docs/Text-Pandoc-Definition.html), the XML conversion is fairly straightforward. + +These are the main rules: + +- `Str` inlines are usually converted to plain, UTF-8 text (see below for exceptions) + +- `Space` inlines are usually converted to " " chars (see below for exceptions) + +- every `Block` and `Inline` becomes an element with the same name and the same capitalization: + a `Para` Block becomes a `` element, an `Emph` Inline becomes an `` element, + and so on; + +- the root element is `` and it has a `api-version` attribute, whose value + is a string of comma-separated integer numbers; it matches the `pandoc-api-version` + field of the `json` format; + +- the root `` element has only two children: `` and `` + (lowercase, as in `json` format); + +- blocks and inlines with an `Attr` are HTM-like, and they have: + + - the `id` attribute for the identifier + + - the `class` attribute, a string of space-separated classes + + - the other attributes of `Attr`, without any prefix (so no `data-` prefix, instead of HTML) + +- attributes are in lower (kebab) case: + + - `level` in Header + + - `start`, `number-style`, `number-delim` in OrderedList; + style and delimiter values are capitalized exactly as in `Text.Pandoc.Definition`; + + - `format` in `RawBlock` and RawInline + + - `quote-type` in Quoted (values are `SingleQuote` and `DoubleQuote`) + + - `math-type` in Math (values are `InlineMath` and `DisplayMath`) + + - `title` and `src` in Image target + + - `title` and `href` in Link target + + - `alignment` and `col-width` in ColSpec (about `col-width` values, see below); + (alignment values are capitalized as in `Text.Pandoc.Definition`) + + - `alignment`, `row-span` and `col-span` in Cell + + - `row-head-columns` in TableBody + + - `id`, `mode`, `note-num` and `hash` for Citation (about Cite elements, see below); + (`mode` values are capitalized as in `Text.Pandoc.Definition`) + +The classes of items with an `Attr` are put in a `class` attribute, +so that you can style the XML with CSS. + +## Str and Space elements + +`Str` and `Space` usually result in text and normal " " spaces, but there are exceptions: + +- `Str ""`, an empty string, is not suppressed; instead it is converted into a `` element; + +- `Str "foo bar"`, a string containing a space, is converted as ``; + +- consecutive `Str` inlines, as in `[ ..., Str "foo", Str "bar", ... ]`, + are encoded as `foo` to keep their individuality; + +- consecutive `Space` inlines, as in `[ ..., Space, Space, ... ]`, + are encoded as `` + +- `Space` inlines at the start or at the end of their container element + are always encoded with a `` element, instead of just a " " + +These encodings are necessary to ensure 1:1 equivalence of the `xml` format with the AST, +or the `native` and `json` formats. + +Since the ones above are corner cases, usually you should not see those `` and `` +elements in your documents. + +## Added tags + +Some other elements have been introduced to better structure the resulting XML. + +Since they are not Pandoc Blocks or Inlines, or they have no constructor or type +in Pandoc's haskell code, they are kept lowercased. + +### BulletList and OrderedList items + +Items of those lists are embedded in `` elements. + +These snippets are from the `xml` version of `test/testsuite.native`: + +```xml + + + asterisk 1 + + + asterisk 2 + + + asterisk 3 + + +... + + + First + + + Second + + + Third + + +``` + +### DefinitionList items + +Definition lists have `` elements. + +Each `` term has only one `` child element, +and one or more `` children elements. + +This snippet is from the `xml` version of `test/testsuite.native`: + +```xml + + + apple + + red fruit + + + + orange + + orange fruit + + + + banana + + yellow fruit + + + +``` + +### Figure and Table captions + +Figures and tables have a `` child element, +which in turn may optionally have a `` child element. + +This snippet is from the `xml` version of `test/testsuite.native`: + +```xml +
+ + lalune + + lalune +
+``` + +### Tables + +A `` element has: + +- a `
` child element; + +- a `` child element, whose children are empty + `` elements; + +- a `` child element; + +- one or more `` children elements, that in turn + have two children: `
` and ``, whose children + are `` elements; + +- a `` child element. + +This specification is debatable; I have these doubts: + +- is it necessary to enclose the `` elements in a `` element? + +- to discriminate between header and data cells in table bodies, + there are the `row-head-columns` attribute, and the `
` and `` children + of the `` element, but there's only one type of cell: + every cell is a `` element + +- the specs are a tradeoff between consistency with pandoc types and CSS compatibility; + this way bodies' header rows are easily stylable with CSS, while header columns are not + +The `ColWidthDefault` value becomes a "0" value for the attribute `col-width`; +this way it's type-consistent with non-zero values, but I'm still doubtful whether to +leave its value as a "ColWidthDefault" string. + +Here's an example from the `xml` version of `test/tables/planets.native`: + +```xml + + + + + + + + + + + + + + + + + + + + + Name + + + Mass (10^24kg) + + ... + + + +
+ + + + Terrestrial planets + + + Mercury + + + 0.330 + + + 4,879 + + + 5427 + + + 3.7 + + + 4222.6 + + + 57.9 + + + 167 + + + 0 + + + Closest to the Sun + + + ... + + + +
+ Data about the planets of our solar system. +
+``` + +### Metadata and MetaMap entries + +Metadata entries are meta values (`MetaBool`, `MetaString`, `MetaInlines`, `MetaBlocks`, +`MetaList` and `MetaMap` elements) inside `` elements. + +The `` and the `` elements have the same children elements (``), +which have a `key` attribute. + +``, ``, `` and `` elements +all have children elements. + +`` elements have only text. + +`` elements are empty, they can be either `` +or ``. + +This snippet is from the `xml` version of `test/testsuite.native`: + +```xml + + + + John MacFarlane + Anonymous + + + + July 17, 2006 + + + Pandoc Test Suite + + +``` + +### Cite elements + +`Cite` inlines are modeled with `` elements, whose first child +is a `` element, that have only `` children elements. + +`` elements are empty, unless they have a prefix and/or a suffix. + +Here's an example from the `xml` version of `test/markdown-citations.native`: + +```xml + + +@item1 says blah. + + + p. 30 + +@item1 [p. 30] says blah. +A citation group + + see + chap. 3 + + + also + p. 34-35 + +[see @item1 chap. 3; also @пункт3 p. 34-35]. +``` diff --git a/pandoc.cabal b/pandoc.cabal index d045f49f7..610723036 100644 --- a/pandoc.cabal +++ b/pandoc.cabal @@ -614,6 +614,7 @@ library Text.Pandoc.Readers.Pod, Text.Pandoc.Writers, Text.Pandoc.Writers.Native, + Text.Pandoc.Writers.XML, Text.Pandoc.Writers.DocBook, Text.Pandoc.Writers.JATS, Text.Pandoc.Writers.OPML, @@ -744,6 +745,7 @@ library Text.Pandoc.Readers.Metadata, Text.Pandoc.Readers.Roff, Text.Pandoc.Readers.Roff.Escape, + Text.Pandoc.Readers.XML, Text.Pandoc.Writers.Docx.OpenXML, Text.Pandoc.Writers.Docx.StyleMap, Text.Pandoc.Writers.Docx.Table, @@ -769,6 +771,7 @@ library Text.Pandoc.Char, Text.Pandoc.TeX, Text.Pandoc.URI, + Text.Pandoc.XMLFormat, Text.Pandoc.CSS, Text.Pandoc.CSV, Text.Pandoc.RoffChar, @@ -815,6 +818,7 @@ test-suite test-pandoc Tests.Helpers Tests.Shared Tests.MediaBag + Tests.XML Tests.Readers.LaTeX Tests.Readers.HTML Tests.Readers.JATS diff --git a/src/Text/Pandoc/Readers.hs b/src/Text/Pandoc/Readers.hs index 8f4800efb..12d1c6c95 100644 --- a/src/Text/Pandoc/Readers.hs +++ b/src/Text/Pandoc/Readers.hs @@ -65,6 +65,7 @@ module Text.Pandoc.Readers , readTypst , readDjot , readPod + , readXML -- * Miscellaneous , getReader , getDefaultExtensions @@ -118,6 +119,7 @@ import Text.Pandoc.Readers.RIS import Text.Pandoc.Readers.RTF import Text.Pandoc.Readers.Typst import Text.Pandoc.Readers.Djot +import Text.Pandoc.Readers.XML import qualified Text.Pandoc.UTF8 as UTF8 import Text.Pandoc.Sources (ToSources(..), sourcesToText) @@ -174,6 +176,7 @@ readers = [("native" , TextReader readNative) ,("djot" , TextReader readDjot) ,("mdoc" , TextReader readMdoc) ,("pod" , TextReader readPod) + ,("xml" , TextReader readXML) ] -- | Retrieve reader, extensions based on format spec (format+extensions). diff --git a/src/Text/Pandoc/Readers/XML.hs b/src/Text/Pandoc/Readers/XML.hs new file mode 100644 index 000000000..e3173f4c9 --- /dev/null +++ b/src/Text/Pandoc/Readers/XML.hs @@ -0,0 +1,540 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- | +-- Module : Text.Pandoc.Readers.XML +-- Copyright : Copyright (C) 2025- Massimiliano Farinella and John MacFarlane +-- License : GNU GPL, version 2 or above +-- +-- Maintainer : Massimiliano Farinella +-- Stability : WIP +-- Portability : portable +-- +-- Conversion of (Pandoc specific) xml to 'Pandoc' document. +module Text.Pandoc.Readers.XML (readXML) where + +import Control.Monad (msum) +import Control.Monad.Except (throwError) +import Control.Monad.State.Strict (StateT (runStateT), modify) +import Data.Char (isSpace) +import Data.Default (Default (..)) +import qualified Data.List as L +import qualified Data.Map as M +import Data.Maybe (catMaybes, fromMaybe, mapMaybe) +import qualified Data.Set as S (Set, fromList, member) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Lazy (fromStrict) +import Data.Version (Version, makeVersion) +import Text.Pandoc.Builder +import Text.Pandoc.Class.PandocMonad +import Text.Pandoc.Error (PandocError (..)) +import Text.Pandoc.Options +import Text.Pandoc.Parsing (ToSources, toSources) +import Text.Pandoc.Sources (sourcesToText) +import Text.Pandoc.Version (pandocVersion) +import Text.Pandoc.XML (lookupEntity) +import Text.Pandoc.XML.Light +import Text.Pandoc.XMLFormat +import Text.Read (readMaybe) + +-- TODO: use xmlPath state to give better context when an error occurs + +type XMLReader m = StateT XMLReaderState m + +data XMLReaderState = XMLReaderState + { xmlApiVersion :: Version, + xmlMeta :: Meta, + xmlContent :: [Content], + xmlPath :: [Text] + } + deriving (Show) + +instance Default XMLReaderState where + def = + XMLReaderState + { xmlApiVersion = pandocVersion, + xmlMeta = mempty, + xmlContent = [], + xmlPath = ["root"] + } + +readXML :: (PandocMonad m, ToSources a) => ReaderOptions -> a -> m Pandoc +readXML _ inp = do + let sources = toSources inp + tree <- + either (throwError . PandocXMLError "") return $ + parseXMLContents (fromStrict . sourcesToText $ sources) + (bs, st') <- flip runStateT (def {xmlContent = tree}) $ mapM parseBlock tree + let blockList = toList $ concatMany bs + return $ Pandoc (xmlMeta st') blockList + +concatMany :: [Many a] -> Many a +concatMany = Many . mconcat . map unMany + +parseBlocks :: (PandocMonad m) => [Content] -> XMLReader m Blocks +parseBlocks contents = concatMany <$> mapM parseBlock contents + +getBlocks :: (PandocMonad m) => Element -> XMLReader m Blocks +getBlocks e = parseBlocks (elContent e) + +elementName :: Element -> Text +elementName e = qName $ elName e + +attrValue :: Text -> Element -> Text +attrValue attr = + fromMaybe "" . maybeAttrValue attr + +maybeAttrValue :: Text -> Element -> Maybe Text +maybeAttrValue attr elt = + lookupAttrBy (\x -> qName x == attr) (elAttribs elt) + +parseBlock :: (PandocMonad m) => Content -> XMLReader m Blocks +parseBlock (Text (CData CDataRaw _ _)) = return mempty -- DOCTYPE +parseBlock (Text (CData _ s _)) = + if T.all isSpace s + then return mempty + else do + throwError $ PandocXMLError "" "non-space characters out of inline context" +parseBlock (CRef x) = do + throwError $ PandocXMLError "" ("reference \"" <> x <> "\" out of inline context") +parseBlock (Elem e) = do + let name = elementName e + in case (name) of + "Pandoc" -> parsePandoc + "?xml" -> return mempty + "blocks" -> getBlocks e + "meta" -> + let entry_els = childrenNamed tgNameMetaMapEntry e + in do + entries <- catMaybes <$> mapM parseMetaMapEntry entry_els + mapM_ (uncurry addMeta) entries + return mempty + "Para" -> para <$> getInlines (elContent e) + "Plain" -> do + ils <- getInlines (elContent e) + return $ singleton . Plain . toList $ ils + "Header" -> (headerWith attr level) <$> getInlines (elContent e) + where + level = textToInt (attrValue atNameLevel e) 1 + attr = filterAttrAttributes [atNameLevel] $ attrFromElement e + "HorizontalRule" -> return horizontalRule + "BlockQuote" -> do + contents <- getBlocks e + return $ blockQuote contents + "Div" -> do + contents <- getBlocks e + return $ divWith (attrFromElement e) contents + "BulletList" -> do + items <- getListItems e + return $ bulletList items + "OrderedList" -> do + items <- getListItems e + return $ orderedListWith (getListAttributes e) items + "DefinitionList" -> do + let items_contents = getContentsOfElements (isElementNamed tgNameDefListItem) (elContent e) + items <- mapM parseDefinitionListItem items_contents + return $ definitionList items + "Figure" -> do + let attr = attrFromElement e + (maybe_caption_el, contents) = partitionFirstChildNamed "Caption" $ elContent e + figure_caption <- case (maybe_caption_el) of + Just (caption_el) -> parseCaption $ elContent caption_el + Nothing -> pure emptyCaption + blocks <- parseBlocks contents + return $ figureWith attr figure_caption blocks + "CodeBlock" -> do + let attr = attrFromElement e + return $ codeBlockWith attr $ strContentRecursive e + "RawBlock" -> do + let format = (attrValue atNameFormat e) + return $ rawBlock format $ strContentRecursive e + "LineBlock" -> do + lins <- mapM getInlines (contentsOfChildren tgNameLineItem (elContent e)) + return $ lineBlock lins + "Table" -> do + -- TODO: check unexpected items + let attr = attrFromElement e + (maybe_caption_el, after_caption) = partitionFirstChildNamed "Caption" $ elContent e + children = elementsWithNames (S.fromList [tgNameColspecs, "TableHead", "TableBody", "TableFoot"]) after_caption + is_element tag el = tag == elementName el + colspecs <- getColspecs $ L.find (is_element tgNameColspecs) children + tbs <- getTableBodies $ filter (is_element "TableBody") children + th <- getTableHead $ L.find (is_element "TableHead") children + tf <- getTableFoot $ L.find (is_element "TableFoot") children + capt <- parseMaybeCaptionElement maybe_caption_el + case colspecs of + Nothing -> return mempty + Just cs -> return $ fromList [Table attr capt cs th tbs tf] + _ -> do + throwError $ PandocXMLError "" ("unexpected element \"" <> name <> "\" in blocks context") + where + parsePandoc = do + let version = maybeAttrValue atNameApiVersion e + apiversion = case (version) of + Just (v) -> makeVersion $ map (read . T.unpack) $ T.splitOn "," v + Nothing -> pandocVersion + in modify $ \st -> st {xmlApiVersion = apiversion} + getBlocks e + +getListItems :: (PandocMonad m) => Element -> XMLReader m [Blocks] +getListItems e = + let items_els = childrenNamed tgNameListItem e + in do + mapM getBlocks items_els + +getContentsOfElements :: (Content -> Bool) -> [Content] -> [[Content]] +getContentsOfElements filter_element contents = mapMaybe element_contents $ filter filter_element contents + where + element_contents :: Content -> Maybe [Content] + element_contents c = case (c) of + Elem e -> Just (elContent e) + _ -> Nothing + +strContentRecursive :: Element -> Text +strContentRecursive = + strContent + . (\e' -> e' {elContent = map elementToStr $ elContent e'}) + +elementToStr :: Content -> Content +elementToStr (Elem e') = Text $ CData CDataText (strContentRecursive e') Nothing +elementToStr x = x + +textToInt :: Text -> Int -> Int +textToInt t deflt = + let safe_to_int :: Text -> Maybe Int + safe_to_int s = readMaybe $ T.unpack s + in case (safe_to_int t) of + Nothing -> deflt + Just (n) -> n + +parseInline :: (PandocMonad m) => Content -> XMLReader m Inlines +parseInline (Text (CData _ s _)) = + return $ text s +parseInline (CRef ref) = + return $ + maybe (text $ T.toUpper ref) text $ + lookupEntity ref +parseInline (Elem e) = + let name = elementName e + in case (name) of + "Space" -> + let count = textToInt (attrValue atNameSpaceCount e) 1 + in return $ fromList $ replicate count Space + "Str" -> return $ fromList [Str $ attrValue atNameStrContent e] + "Emph" -> innerInlines emph + "Strong" -> innerInlines strong + "Strikeout" -> innerInlines strikeout + "Subscript" -> innerInlines subscript + "Superscript" -> innerInlines superscript + "Underline" -> innerInlines underline + "SoftBreak" -> return softbreak + "LineBreak" -> return linebreak + "SmallCaps" -> innerInlines smallcaps + "Quoted" -> case (attrValue atNameQuoteType e) of + "SingleQuote" -> innerInlines singleQuoted + _ -> innerInlines doubleQuoted + "Math" -> case (attrValue atNameMathType e) of + "DisplayMath" -> pure $ displayMath $ strContentRecursive e + _ -> pure $ math $ strContentRecursive e + "Span" -> innerInlines $ spanWith (attrFromElement e) + "Code" -> do + let attr = attrFromElement e + return $ codeWith attr $ strContentRecursive e + "Link" -> innerInlines $ linkWith attr url title + where + url = attrValue atNameLinkUrl e + title = attrValue atNameTitle e + attr = filterAttrAttributes [atNameLinkUrl, atNameTitle] $ attrFromElement e + "Image" -> innerInlines $ imageWith attr url title + where + url = attrValue atNameImageUrl e + title = attrValue atNameTitle e + attr = filterAttrAttributes [atNameImageUrl, atNameTitle] $ attrFromElement e + "RawInline" -> do + let format = (attrValue atNameFormat e) + return $ rawInline format $ strContentRecursive e + "Note" -> do + contents <- getBlocks e + return $ note contents + "Cite" -> + let (maybe_citations_el, contents) = partitionFirstChildNamed tgNameCitations $ elContent e + in case (maybe_citations_el) of + Just citations_el -> do + citations <- parseCitations $ elContent citations_el + (innerInlines' contents) $ cite citations + Nothing -> getInlines contents + _ -> do + throwError $ PandocXMLError "" ("unexpected element \"" <> name <> "\" in inline context") + where + innerInlines' contents f = + f . concatMany + <$> mapM parseInline contents + innerInlines f = innerInlines' (elContent e) f + +getInlines :: (PandocMonad m) => [Content] -> XMLReader m Inlines +getInlines contents = concatMany <$> mapM parseInline contents + +getListAttributes :: Element -> ListAttributes +getListAttributes e = (start, style, delim) + where + start = textToInt (attrValue atNameStart e) 1 + style = case (attrValue atNameNumberStyle e) of + "Example" -> Example + "Decimal" -> Decimal + "LowerRoman" -> LowerRoman + "UpperRoman" -> UpperRoman + "LowerAlpha" -> LowerAlpha + "UpperAlpha" -> UpperAlpha + _ -> DefaultStyle + delim = case (attrValue atNameNumberDelim e) of + "Period" -> Period + "OneParen" -> OneParen + "TwoParens" -> TwoParens + _ -> DefaultDelim + +contentsOfChildren :: Text -> [Content] -> [[Content]] +contentsOfChildren tag contents = mapMaybe childrenElementWithTag contents + where + childrenElementWithTag :: Content -> Maybe [Content] + childrenElementWithTag c = case (c) of + (Elem e) -> if tag == elementName e then Just (elContent e) else Nothing + _ -> Nothing + +alignmentFromText :: Text -> Alignment +alignmentFromText t = case t of + "AlignLeft" -> AlignLeft + "AlignRight" -> AlignRight + "AlignCenter" -> AlignCenter + _ -> AlignDefault + +getColWidth :: Text -> ColWidth +getColWidth txt = case reads (T.unpack txt) of + [(value, "")] -> if value == 0.0 then ColWidthDefault else ColWidth value + _ -> ColWidthDefault + +getColspecs :: (PandocMonad m) => Maybe Element -> XMLReader m (Maybe [ColSpec]) +getColspecs Nothing = pure Nothing +getColspecs (Just cs) = do + return $ Just $ map elementToColSpec (childrenNamed "ColSpec" cs) + where + elementToColSpec e = (alignmentFromText $ attrValue atNameAlignment e, getColWidth $ attrValue atNameColWidth e) + +getTableBody :: (PandocMonad m) => Element -> XMLReader m (Maybe TableBody) +getTableBody body_el = do + let attr = filterAttrAttributes [atNameRowHeadColumns] $ attrFromElement body_el + bh = childrenNamed tgNameBodyHeader body_el + bb = childrenNamed tgNameBodyBody body_el + headcols = textToInt (attrValue atNameRowHeadColumns body_el) 0 + hrows <- mconcat <$> mapM getRows bh + brows <- mconcat <$> mapM getRows bb + return $ Just $ TableBody attr (RowHeadColumns headcols) hrows brows + +getTableBodies :: (PandocMonad m) => [Element] -> XMLReader m [TableBody] +getTableBodies body_elements = do + catMaybes <$> mapM getTableBody body_elements + +getTableHead :: (PandocMonad m) => Maybe Element -> XMLReader m TableHead +getTableHead maybe_e = case maybe_e of + Just e -> do + let attr = attrFromElement e + rows <- getRows e + return $ TableHead attr rows + Nothing -> return $ TableHead nullAttr [] + +getTableFoot :: (PandocMonad m) => Maybe Element -> XMLReader m TableFoot +getTableFoot maybe_e = case maybe_e of + Just e -> do + let attr = attrFromElement e + rows <- getRows e + return $ TableFoot attr rows + Nothing -> return $ TableFoot nullAttr [] + +getCell :: (PandocMonad m) => Element -> XMLReader m Cell +getCell c = do + let alignment = alignmentFromText $ attrValue atNameAlignment c + rowspan = RowSpan $ textToInt (attrValue atNameRowspan c) 1 + colspan = ColSpan $ textToInt (attrValue atNameColspan c) 1 + attr = filterAttrAttributes [atNameAlignment, atNameRowspan, atNameColspan] $ attrFromElement c + blocks <- getBlocks c + return $ Cell attr alignment rowspan colspan (toList blocks) + +getRows :: (PandocMonad m) => Element -> XMLReader m [Row] +getRows e = mapM getRow $ childrenNamed "Row" e + where + getRow r = do + cells <- mapM getCell (childrenNamed "Cell" r) + return $ Row (attrFromElement r) cells + +parseCitations :: (PandocMonad m) => [Content] -> XMLReader m [Citation] +parseCitations contents = do + maybecitations <- mapM getCitation contents + return $ catMaybes maybecitations + where + getCitation :: (PandocMonad m) => Content -> XMLReader m (Maybe Citation) + getCitation content = case (content) of + (Elem e) -> + if qName (elName e) == "Citation" + then do + p <- inlinesOfChildrenNamed tgNameCitationPrefix e + s <- inlinesOfChildrenNamed tgNameCitationSuffix e + return $ + Just + ( Citation + { citationId = attrValue "id" e, + citationPrefix = toList p, + citationSuffix = toList s, + citationMode = case (attrValue atNameCitationMode e) of + "AuthorInText" -> AuthorInText + "SuppressAuthor" -> SuppressAuthor + _ -> NormalCitation, + citationNoteNum = textToInt (attrValue atNameCitationNoteNum e) 0, + citationHash = textToInt (attrValue atNameCitationHash e) 0 + } + ) + else do + return Nothing + _ -> do + return Nothing + where + inlinesOfChildrenNamed tag e = getInlines $ concatMap (\e' -> elContent e') (childrenNamed tag e) + +parseMaybeCaptionElement :: (PandocMonad m) => Maybe Element -> XMLReader m Caption +parseMaybeCaptionElement Nothing = pure emptyCaption +parseMaybeCaptionElement (Just e) = parseCaption $ elContent e + +parseCaption :: (PandocMonad m) => [Content] -> XMLReader m Caption +parseCaption contents = + let (maybe_shortcaption_el, caption_contents) = partitionFirstChildNamed tgNameShortCaption contents + in do + blocks <- parseBlocks caption_contents + case (maybe_shortcaption_el) of + Just shortcaption_el -> do + short_caption <- getInlines (elContent shortcaption_el) + return $ caption (Just $ toList short_caption) blocks + Nothing -> return $ caption Nothing blocks + +parseDefinitionListItem :: (PandocMonad m) => [Content] -> XMLReader m (Inlines, [Blocks]) +parseDefinitionListItem contents = do + let term_contents = getContentsOfElements (isElementNamed tgNameDefListTerm) contents + defs_elements = elementContents $ filter (isElementNamed tgNameDefListDef) contents + term_inlines <- getInlines (concat term_contents) + defs <- mapM getBlocks defs_elements + return (term_inlines, defs) + +elementContents :: [Content] -> [Element] +elementContents contents = mapMaybe toElement contents + where + toElement :: Content -> Maybe Element + toElement (Elem e) = Just e + toElement _ = Nothing + +isElementNamed :: Text -> Content -> Bool +isElementNamed t c = case (c) of + Elem e -> t == elementName e + _ -> False + +childrenNamed :: Text -> Element -> [Element] +childrenNamed tag e = elementContents $ filter (isElementNamed tag) (elContent e) + +elementsWithNames :: S.Set Text -> [Content] -> [Element] +elementsWithNames tags contents = mapMaybe isElementWithNameInSet contents + where + isElementWithNameInSet c = case (c) of + Elem el -> + if (elementName el) `S.member` tags + then Just el + else Nothing + _ -> Nothing + +partitionFirstChildNamed :: Text -> [Content] -> (Maybe Element, [Content]) +partitionFirstChildNamed tag contents = case (contents) of + (Text (CData _ s _) : rest) -> + if T.all isSpace s + then partitionFirstChildNamed tag rest + else (Nothing, contents) + (Elem e : rest) -> + if tag == elementName e + then (Just e, rest) + else (Nothing, contents) + _ -> (Nothing, contents) + +type PandocAttr = (Text, [Text], [(Text, Text)]) + +filterAttributes :: S.Set Text -> [(Text, Text)] -> [(Text, Text)] +filterAttributes to_be_removed a = filter keep_attr a + where + keep_attr (k, _) = not (k `S.member` to_be_removed) + +filterAttrAttributes :: [Text] -> PandocAttr -> PandocAttr +filterAttrAttributes to_be_removed (idn, classes, a) = (idn, classes, filtered) + where + filtered = filterAttributes (S.fromList to_be_removed) a + +attrFromElement :: Element -> PandocAttr +attrFromElement e = filterAttrAttributes ["id", "class"] (idn, classes, attributes) + where + idn = attrValue "id" e + classes = T.words $ attrValue "class" e + attributes = map (\a -> (qName $ attrKey a, attrVal a)) $ elAttribs e + +addMeta :: (PandocMonad m) => (ToMetaValue a) => Text -> a -> XMLReader m () +addMeta field val = modify (setMeta field val) + +instance HasMeta XMLReaderState where + setMeta field v s = s {xmlMeta = setMeta field v (xmlMeta s)} + + deleteMeta field s = s {xmlMeta = deleteMeta field (xmlMeta s)} + +parseMetaMapEntry :: (PandocMonad m) => Element -> XMLReader m (Maybe (Text, MetaValue)) +parseMetaMapEntry e = + let key = attrValue atNameMetaMapEntryKey e + in case (key) of + "" -> pure Nothing + k -> do + maybe_value <- parseMetaMapEntryContents $ elContent e + case (maybe_value) of + Nothing -> return Nothing + Just v -> return $ Just (k, v) + +parseMetaMapEntryContents :: (PandocMonad m) => [Content] -> XMLReader m (Maybe MetaValue) +parseMetaMapEntryContents cs = msum <$> mapM parseMeta cs + +parseMeta :: (PandocMonad m) => Content -> XMLReader m (Maybe MetaValue) +parseMeta (Text (CData CDataRaw _ _)) = return Nothing +parseMeta (Text (CData _ s _)) = + if T.all isSpace s + then return Nothing + else do + throwError $ PandocXMLError "" "non-space characters out of inline context in metadata" +parseMeta (CRef x) = + throwError $ PandocXMLError "" ("reference \"" <> x <> "\" out of inline context") +parseMeta (Elem e) = do + let name = elementName e + in case (name) of + "MetaBool" -> case (attrValue atNameMetaBoolValue e) of + "true" -> return $ Just $ MetaBool True + _ -> return $ Just $ MetaBool False + "MetaString" -> pure Nothing + "MetaInlines" -> do + inlines <- getInlines (elContent e) + return $ Just $ MetaInlines $ toList inlines + "MetaBlocks" -> do + blocks <- getBlocks e + return $ Just $ MetaBlocks $ toList blocks + "MetaList" -> do + maybe_items <- mapM parseMeta $ elContent e + let items = catMaybes maybe_items + in -- TODO: report empty MetaList? + return $ Just $ MetaList items + "MetaMap" -> + let entry_els = childrenNamed tgNameMetaMapEntry e + in do + entries <- catMaybes <$> mapM parseMetaMapEntry entry_els + if null entries + then + -- TODO: report empty MetaMap + return Nothing + else return $ Just $ MetaMap $ M.fromList entries + _ -> do + throwError $ PandocXMLError "" ("unexpected element \"" <> name <> "\" in metadata") diff --git a/src/Text/Pandoc/Writers.hs b/src/Text/Pandoc/Writers.hs index 3e1974ad6..164bf6d0f 100644 --- a/src/Text/Pandoc/Writers.hs +++ b/src/Text/Pandoc/Writers.hs @@ -76,6 +76,7 @@ module Text.Pandoc.Writers , writeTexinfo , writeTextile , writeTypst + , writeXML , writeXWiki , writeZimWiki , getWriter @@ -128,6 +129,7 @@ import Text.Pandoc.Writers.TEI import Text.Pandoc.Writers.Texinfo import Text.Pandoc.Writers.Textile import Text.Pandoc.Writers.Typst +import Text.Pandoc.Writers.XML import Text.Pandoc.Writers.XWiki import Text.Pandoc.Writers.ZimWiki @@ -203,6 +205,7 @@ writers = [ ,("chunkedhtml" , ByteStringWriter writeChunkedHTML) ,("djot" , TextWriter writeDjot) ,("ansi" , TextWriter writeANSI) + ,("xml" , TextWriter writeXML) ] -- | Retrieve writer, extensions based on formatSpec (format+extensions). diff --git a/src/Text/Pandoc/Writers/XML.hs b/src/Text/Pandoc/Writers/XML.hs new file mode 100644 index 000000000..35bcb07f8 --- /dev/null +++ b/src/Text/Pandoc/Writers/XML.hs @@ -0,0 +1,365 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- | +-- Module : Text.Pandoc.Writers.XML +-- Copyright : Copyright (C) 2025- Massimiliano Farinella and John MacFarlane +-- License : GNU GPL, version 2 or above +-- +-- Maintainer : Massimiliano Farinella +-- Stability : WIP +-- Portability : portable +-- +-- Conversion of 'Pandoc' documents to (pandoc specific) xml markup. +module Text.Pandoc.Writers.XML (writeXML) where + +import Data.Map (Map, toList) +import Data.Maybe (mapMaybe) +import qualified Data.Text as T +import Data.Version (versionBranch) +import Text.Pandoc.Class.PandocMonad (PandocMonad) +import Text.Pandoc.Definition +import Text.Pandoc.Options (WriterOptions (..)) +import Text.Pandoc.XML.Light +import qualified Text.Pandoc.XML.Light as XML +import Text.Pandoc.XMLFormat +import Text.XML.Light (xml_header) + +type PandocAttr = Text.Pandoc.Definition.Attr + +writeXML :: (PandocMonad m) => WriterOptions -> Pandoc -> m T.Text +writeXML _ doc = do + return $ pandocToXmlText doc + +text_node :: T.Text -> Content +text_node text = Text (CData CDataText text Nothing) + +emptyElement :: T.Text -> Element +emptyElement tag = + Element + { elName = unqual tag, + elAttribs = [], + elContent = [], + elLine = Nothing + } + +elementWithContents :: T.Text -> [Content] -> Element +elementWithContents tag contents = + Element + { elName = unqual tag, + elAttribs = [], + elContent = contents, + elLine = Nothing + } + +elementWithAttributes :: T.Text -> [XML.Attr] -> Element +elementWithAttributes tag attributes = + Element + { elName = unqual tag, + elAttribs = attributes, + elContent = [], + elLine = Nothing + } + +elementWithAttrAndContents :: T.Text -> PandocAttr -> [Content] -> Element +elementWithAttrAndContents tag attr contents = addAttrAttributes attr $ elementWithContents tag contents + +asBlockOfInlines :: Element -> [Content] +asBlockOfInlines el = [Elem el, text_node "\n"] + +asBlockOfBlocks :: Element -> [Content] +asBlockOfBlocks el = [Elem newline_before_first, newline] + where + newline = text_node "\n" + newline_before_first = if null (elContent el) then el else prependContents [newline] el + +itemName :: (Show a) => a -> T.Text +itemName a = T.pack $ takeWhile (/= ' ') (show a) + +intAsText :: Int -> T.Text +intAsText i = T.pack $ show i + +itemAsEmptyElement :: (Show a) => a -> Element +itemAsEmptyElement item = emptyElement $ itemName item + +pandocToXmlText :: Pandoc -> T.Text +pandocToXmlText (Pandoc (Meta meta) blocks) = with_header . with_blocks . with_meta . with_version $ el + where + el = prependContents [text_node "\n"] $ emptyElement "Pandoc" + with_version = addAttribute atNameApiVersion (T.intercalate "," $ map (T.pack . show) $ versionBranch pandocTypesVersion) + with_meta = appendContents (metaMapToXML meta "meta") + with_blocks = appendContents (asBlockOfBlocks $ elementWithContents "blocks" $ blocksToXML blocks) + with_header :: Element -> T.Text + with_header e = T.concat [T.pack xml_header, "\n", showElement e] + +metaMapToXML :: Map T.Text MetaValue -> T.Text -> [Content] +metaMapToXML mmap tag = asBlockOfBlocks $ elementWithContents tag entries + where + entries = concatMap to_entry $ toList mmap + to_entry :: (T.Text, MetaValue) -> [Content] + to_entry (text, metavalue) = asBlockOfBlocks with_key + where + entry = elementWithContents tgNameMetaMapEntry $ metaValueToXML metavalue + with_key = addAttribute atNameMetaMapEntryKey text entry + +metaValueToXML :: MetaValue -> [Content] +metaValueToXML value = + let name = itemName value + el = itemAsEmptyElement value + in case (value) of + MetaBool b -> asBlockOfInlines $ addAttribute atNameMetaBoolValue bool_value el + where + bool_value = if b then "true" else "false" + MetaString s -> asBlockOfInlines $ appendContents [text_node s] el + MetaInlines inlines -> asBlockOfInlines $ appendContents (inlinesToXML inlines) el + MetaBlocks blocks -> asBlockOfBlocks $ appendContents (blocksToXML blocks) el + MetaList items -> asBlockOfBlocks $ appendContents (concatMap metaValueToXML items) el + MetaMap mm -> metaMapToXML mm name + +blocksToXML :: [Block] -> [Content] +blocksToXML blocks = concatMap blockToXML blocks + +inlinesToXML :: [Inline] -> [Content] +inlinesToXML inlines = concatMap inlineContentToContents (ilsToIlsContent inlines []) + +data InlineContent + = NormalInline Inline + | ElSpace Int + | ElStr T.Text + +ilsToIlsContent :: [Inline] -> [InlineContent] -> [InlineContent] +ilsToIlsContent (Space : xs) [] = ilsToIlsContent xs [ElSpace 1] +ilsToIlsContent (Space : xs) (NormalInline Space : cs) = ilsToIlsContent xs (ElSpace 2 : cs) +ilsToIlsContent (Space : xs) (ElSpace n : cs) = ilsToIlsContent xs (ElSpace (n + 1) : cs) +-- empty Str are always encoded as +ilsToIlsContent (Str "" : xs) ilct = ilsToIlsContent xs (ElStr "" : ilct) +-- Str s1, Str s2 -> s1 +ilsToIlsContent (Str s2 : xs) (NormalInline str1@(Str _) : ilct) = ilsToIlsContent xs (ElStr s2 : NormalInline str1 : ilct) +-- +ilsToIlsContent (Str s : xs) ilct = + if T.any (== ' ') s + then ilsToIlsContent xs (ElStr s : ilct) + else ilsToIlsContent xs (NormalInline (Str s) : ilct) +ilsToIlsContent (x : xs) ilct = ilsToIlsContent xs (NormalInline x : ilct) +ilsToIlsContent [] ilct = reverse $ lastSpaceAsElem ilct + where + lastSpaceAsElem :: [InlineContent] -> [InlineContent] + lastSpaceAsElem (NormalInline Space : xs) = ElSpace 1 : xs + lastSpaceAsElem ilcts = ilcts + +inlineContentToContents :: InlineContent -> [Content] +inlineContentToContents (NormalInline il) = inlineToXML il +inlineContentToContents (ElSpace 1) = [Elem $ emptyElement "Space"] +inlineContentToContents (ElSpace n) = [Elem $ addAttribute atNameSpaceCount (intAsText n) (emptyElement "Space")] +inlineContentToContents (ElStr "") = [Elem $ emptyElement "Str"] +inlineContentToContents (ElStr s) = [Elem $ addAttribute atNameStrContent s (emptyElement "Str")] + +asContents :: Element -> [Content] +asContents el = [Elem el] + +wrapBlocks :: T.Text -> [Block] -> [Content] +wrapBlocks tag blocks = asBlockOfBlocks $ elementWithContents tag $ blocksToXML blocks + +wrapArrayOfBlocks :: T.Text -> [[Block]] -> [Content] +wrapArrayOfBlocks tag array = concatMap (wrapBlocks tag) array + +-- wrapInlines :: T.Text -> [Inline] -> [Content] +-- wrapInlines tag inlines = asBlockOfInlines $ element_with_contents tag $ inlinesToXML inlines + +blockToXML :: Block -> [Content] +blockToXML block = + let el = itemAsEmptyElement block + in case (block) of + Para inlines -> asBlockOfInlines $ appendContents (inlinesToXML inlines) el + Header level (idn, cls, attrs) inlines -> asBlockOfInlines $ appendContents (inlinesToXML inlines) with_attr + where + with_attr = addAttrAttributes (idn, cls, attrs ++ [(atNameLevel, intAsText level)]) el + Plain inlines -> asBlockOfInlines $ appendContents (inlinesToXML inlines) el + Div attr blocks -> asBlockOfBlocks $ appendContents (blocksToXML blocks) with_attr + where + with_attr = addAttrAttributes attr el + BulletList items -> asBlockOfBlocks $ appendContents (wrapArrayOfBlocks tgNameListItem items) el + OrderedList (start, style, delim) items -> asBlockOfBlocks $ with_contents . with_attrs $ el + where + with_attrs = + addAttributes + ( validAttributes + [ (atNameStart, intAsText start), + (atNameNumberStyle, itemName style), + (atNameNumberDelim, itemName delim) + ] + ) + with_contents = appendContents (wrapArrayOfBlocks tgNameListItem items) + BlockQuote blocks -> asBlockOfBlocks $ appendContents (blocksToXML blocks) el + HorizontalRule -> asBlockOfInlines el + CodeBlock attr text -> asBlockOfInlines $ with_contents . with_attr $ el + where + with_contents = appendContents [text_node text] + with_attr = addAttrAttributes attr + LineBlock lins -> asBlockOfBlocks $ appendContents (concatMap wrapInlines lins) el + where + wrapInlines inlines = asContents $ appendContents (inlinesToXML inlines) $ emptyElement tgNameLineItem + Table attr caption colspecs thead tbodies tfoot -> asBlockOfBlocks $ with_foot . with_bodies . with_head . with_colspecs . with_caption . with_attr $ el + where + with_attr = addAttrAttributes attr + with_caption = appendContents (captionToXML caption) + with_colspecs = appendContents (colSpecsToXML colspecs) + with_head = appendContents (tableHeadToXML thead) + with_bodies = appendContents (concatMap tableBodyToXML tbodies) + with_foot = appendContents (tableFootToXML tfoot) + Figure attr caption blocks -> asBlockOfBlocks $ with_contents . with_caption . with_attr $ el + where + with_attr = addAttrAttributes attr + with_caption = appendContents (captionToXML caption) + with_contents = appendContents (blocksToXML blocks) + RawBlock (Format format) text -> asContents $ appendContents [text_node text] raw + where + raw = addAttribute atNameFormat format el + DefinitionList items -> asBlockOfBlocks $ appendContents (map definitionListItemToXML items) el + +inlineToXML :: Inline -> [Content] +inlineToXML inline = + let el = itemAsEmptyElement inline + wrapInlines inlines = asContents $ appendContents (inlinesToXML inlines) el + in case (inline) of + Space -> [text_node " "] + Str s -> [text_node s] + Emph inlines -> wrapInlines inlines + Strong inlines -> wrapInlines inlines + Quoted quote_type inlines -> asContents $ appendContents (inlinesToXML inlines) quoted + where + quoted = addAttribute atNameQuoteType (itemName quote_type) el + Underline inlines -> wrapInlines inlines + Strikeout inlines -> wrapInlines inlines + SmallCaps inlines -> wrapInlines inlines + Superscript inlines -> wrapInlines inlines + Subscript inlines -> wrapInlines inlines + SoftBreak -> asContents el + LineBreak -> asContents el + Span attr inlines -> asContents $ appendContents (inlinesToXML inlines) with_attr + where + with_attr = addAttrAttributes attr el + Link (idn, cls, attrs) inlines (url, title) -> asContents $ appendContents (inlinesToXML inlines) with_attr + where + with_attr = addAttrAttributes (idn, cls, attrs ++ [(atNameLinkUrl, url), (atNameTitle, title)]) el + Image (idn, cls, attrs) inlines (url, title) -> asContents $ appendContents (inlinesToXML inlines) with_attr + where + with_attr = addAttrAttributes (idn, cls, attrs ++ [(atNameImageUrl, url), (atNameTitle, title)]) el + RawInline (Format format) text -> asContents $ appendContents [text_node text] raw + where + raw = addAttribute atNameFormat format el + Math math_type text -> asContents $ appendContents [text_node text] math + where + math = addAttribute atNameMathType (itemName math_type) el + Code attr text -> asContents $ appendContents [text_node text] with_attr + where + with_attr = addAttrAttributes attr el + Note blocks -> asContents $ appendContents (blocksToXML blocks) el + Cite citations inlines -> asContents $ appendContents (inlinesToXML inlines) with_citations + where + with_citations = addCitations citations el + +-- TODO: don't let an attribute overwrite id or class +maybeAttribute :: (T.Text, T.Text) -> Maybe XML.Attr +maybeAttribute (_, "") = Nothing +maybeAttribute ("", _) = Nothing +maybeAttribute (name, value) = Just $ XML.Attr (unqual name) value + +validAttributes :: [(T.Text, T.Text)] -> [XML.Attr] +validAttributes pairs = mapMaybe maybeAttribute pairs + +appendContents :: [Content] -> Element -> Element +appendContents newContents el = el {elContent = (elContent el) ++ newContents} + +prependContents :: [Content] -> Element -> Element +prependContents newContents el = el {elContent = newContents ++ (elContent el)} + +addAttributes :: [XML.Attr] -> Element -> Element +addAttributes newAttrs el = el {elAttribs = newAttrs ++ elAttribs el} + +addAttribute :: T.Text -> T.Text -> Element -> Element +addAttribute attr_name attr_value el = el {elAttribs = new_attr : elAttribs el} + where + new_attr = XML.Attr (unqual attr_name) attr_value + +addAttrAttributes :: PandocAttr -> Element -> Element +addAttrAttributes (identifier, classes, attributes) el = addAttributes attrs' el + where + attrs' = mapMaybe maybeAttribute (("id", identifier) : ("class", T.intercalate " " classes) : attributes) + +addCitations :: [Citation] -> Element -> Element +addCitations citations el = appendContents [Elem $ elementWithContents tgNameCitations $ (text_node "\n") : concatMap citation_to_elem citations] el + where + citation_to_elem :: Citation -> [Content] + citation_to_elem citation = asBlockOfInlines with_suffix + where + cit_elem = elementWithAttributes (itemName citation) attrs + prefix = citationPrefix citation + suffix = citationSuffix citation + with_prefix = + if null prefix + then cit_elem + else appendContents [Elem $ elementWithContents tgNameCitationPrefix $ inlinesToXML prefix] cit_elem + with_suffix = + if null suffix + then with_prefix + else appendContents [Elem $ elementWithContents tgNameCitationSuffix $ inlinesToXML suffix] with_prefix + attrs = + map + (\(n, v) -> XML.Attr (unqual n) v) + [ ("id", citationId citation), + (atNameCitationMode, T.pack $ show $ citationMode citation), + (atNameCitationNoteNum, intAsText $ citationNoteNum citation), + (atNameCitationHash, intAsText $ citationHash citation) + ] + +definitionListItemToXML :: ([Inline], [[Block]]) -> Content +definitionListItemToXML (inlines, defs) = Elem $ elementWithContents tgNameDefListItem $ term ++ wrapArrayOfBlocks tgNameDefListDef defs + where + term = asBlockOfInlines $ appendContents (inlinesToXML inlines) $ emptyElement tgNameDefListTerm + +captionToXML :: Caption -> [Content] +captionToXML (Caption short blocks) = asBlockOfBlocks with_short_caption + where + el = elementWithContents "Caption" $ blocksToXML blocks + with_short_caption = case (short) of + Just inlines -> prependContents (asBlockOfInlines $ elementWithContents tgNameShortCaption $ inlinesToXML inlines) el + _ -> el + +colSpecToXML :: (Alignment, ColWidth) -> [Content] +colSpecToXML (align, cw) = asBlockOfInlines colspec + where + colspec = elementWithAttributes "ColSpec" $ validAttributes [(atNameAlignment, itemName align), (atNameColWidth, colwidth)] + colwidth = case (cw) of + ColWidth d -> T.pack $ show d + ColWidthDefault -> "0" + +colSpecsToXML :: [(Alignment, ColWidth)] -> [Content] +colSpecsToXML colspecs = asBlockOfBlocks $ elementWithContents tgNameColspecs $ concatMap colSpecToXML colspecs + +tableHeadToXML :: TableHead -> [Content] +tableHeadToXML (TableHead attr rows) = asBlockOfBlocks $ elementWithAttrAndContents "TableHead" attr $ concatMap rowToXML rows + +tableBodyToXML :: TableBody -> [Content] +tableBodyToXML (TableBody (idn, cls, attrs) (RowHeadColumns headcols) hrows brows) = asBlockOfBlocks $ elementWithAttrAndContents "TableBody" attr children + where + attr = (idn, cls, (atNameRowHeadColumns, intAsText headcols) : attrs) + header_rows = asBlockOfBlocks $ elementWithContents tgNameBodyHeader $ concatMap rowToXML hrows + body_rows = asBlockOfBlocks $ elementWithContents tgNameBodyBody $ concatMap rowToXML brows + children = header_rows ++ body_rows + +tableFootToXML :: TableFoot -> [Content] +tableFootToXML (TableFoot attr rows) = asBlockOfBlocks $ elementWithAttrAndContents "TableFoot" attr $ concatMap rowToXML rows + +rowToXML :: Row -> [Content] +rowToXML (Row attr cells) = asBlockOfBlocks $ elementWithAttrAndContents "Row" attr $ concatMap cellToXML cells + +cellToXML :: Cell -> [Content] +cellToXML (Cell (idn, cls, attrs) alignment (RowSpan rowspan) (ColSpan colspan) blocks) = asBlockOfBlocks $ elementWithAttrAndContents "Cell" attr $ blocksToXML blocks + where + with_alignment a = (atNameAlignment, itemName alignment) : a + with_rowspan a = if rowspan > 1 then (atNameRowspan, intAsText rowspan) : a else a + with_colspan a = if colspan > 1 then (atNameColspan, intAsText colspan) : a else a + attrs' = (with_colspan . with_rowspan . with_alignment) attrs + attr = (idn, cls, attrs') diff --git a/src/Text/Pandoc/XMLFormat.hs b/src/Text/Pandoc/XMLFormat.hs new file mode 100644 index 000000000..6801de90c --- /dev/null +++ b/src/Text/Pandoc/XMLFormat.hs @@ -0,0 +1,188 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Text.Pandoc.XMLFormat + ( atNameAlignment, + atNameApiVersion, + atNameCitationHash, + atNameCitationMode, + atNameCitationNoteNum, + atNameColspan, + atNameColWidth, + atNameFormat, + atNameImageUrl, + atNameLevel, + atNameLinkUrl, + atNameMathType, + atNameMetaBoolValue, + atNameMetaMapEntryKey, + atNameNumberDelim, + atNameNumberStyle, + atNameQuoteType, + atNameRowHeadColumns, + atNameRowspan, + atNameSpaceCount, + atNameStart, + atNameStrContent, + atNameTitle, + tgNameBodyBody, + tgNameBodyHeader, + tgNameCitations, + tgNameCitationPrefix, + tgNameCitationSuffix, + tgNameColspecs, + tgNameDefListDef, + tgNameDefListItem, + tgNameDefListTerm, + tgNameLineItem, + tgNameListItem, + tgNameMetaMapEntry, + tgNameShortCaption, + ) +where + +import Data.Text (Text) + +-- the attribute carrying the API version of pandoc types in the main Pandoc element +atNameApiVersion :: Text +atNameApiVersion = "api-version" + +-- the element of a or entry +tgNameMetaMapEntry :: Text +tgNameMetaMapEntry = "entry" + +-- the attribute carrying the key name of a or entry +atNameMetaMapEntryKey :: Text +atNameMetaMapEntryKey = "key" + +-- the attribute carrying the boolean value ("true" or "false") of a MetaBool +atNameMetaBoolValue :: Text +atNameMetaBoolValue = "value" + +-- level of a Header +atNameLevel :: Text +atNameLevel = "level" + +-- start number of an OrderedList +atNameStart :: Text +atNameStart = "start" + +-- number delimiter of an OrderedList +atNameNumberDelim :: Text +atNameNumberDelim = "number-delim" + +-- number style of an OrderedList +atNameNumberStyle :: Text +atNameNumberStyle = "number-style" + +-- target title in Image and Link +atNameTitle :: Text +atNameTitle = "title" + +-- target url in Image +atNameImageUrl :: Text +atNameImageUrl = "src" + +-- target url in Link +atNameLinkUrl :: Text +atNameLinkUrl = "href" + +-- QuoteType of a Quoted +atNameQuoteType :: Text +atNameQuoteType = "quote-type" + +-- MathType of a Math +atNameMathType :: Text +atNameMathType = "math-type" + +-- format of a RawInline or a RawBlock +atNameFormat :: Text +atNameFormat = "format" + +-- alignment attribute in a ColSpec or in a Cell +atNameAlignment :: Text +atNameAlignment = "alignment" + +-- ColWidth attribute in a ColSpec +atNameColWidth :: Text +atNameColWidth = "col-width" + +-- RowHeadColumns attribute in a TableBody +atNameRowHeadColumns :: Text +atNameRowHeadColumns = "row-head-columns" + +-- RowSpan attribute in a Cell +atNameRowspan :: Text +atNameRowspan = "row-span" + +-- ColSpan attribute in a Cell +atNameColspan :: Text +atNameColspan = "col-span" + +-- the citationMode of a Citation +atNameCitationMode :: Text +atNameCitationMode = "mode" + +-- the citationHash of a Citation +atNameCitationHash :: Text +atNameCitationHash = "hash" + +-- the citationNoteNum of a Citation +atNameCitationNoteNum :: Text +atNameCitationNoteNum = "note-num" + +-- the number of consecutive spaces of the element +atNameSpaceCount :: Text +atNameSpaceCount = "count" + +-- the content of the element +atNameStrContent :: Text +atNameStrContent = "content" + +-- container of Citation elements in Cite inlines +tgNameCitations :: Text +tgNameCitations = "citations" + +-- element around the prefix inlines of a Citation +tgNameCitationPrefix :: Text +tgNameCitationPrefix = "prefix" + +-- element around the suffix inlines of a Citation +tgNameCitationSuffix :: Text +tgNameCitationSuffix = "suffix" + +-- list item for BulletList and OrderedList +tgNameListItem :: Text +tgNameListItem = "item" + +-- list item for DefinitionList +tgNameDefListItem :: Text +tgNameDefListItem = "item" + +-- element around the inlines of the term of a DefinitionList item +tgNameDefListTerm :: Text +tgNameDefListTerm = "term" + +-- element around the blocks of a definition in a DefinitionList item +tgNameDefListDef :: Text +tgNameDefListDef = "def" + +-- optional element of the ShortCaption +tgNameShortCaption :: Text +tgNameShortCaption = "ShortCaption" + +-- element around the ColSpec of a Table +tgNameColspecs :: Text +tgNameColspecs = "colspecs" + +-- element around the header rows of a TableBody +tgNameBodyHeader :: Text +tgNameBodyHeader = "header" + +-- element around the body rows of a TableBody +tgNameBodyBody :: Text +tgNameBodyBody = "body" + +-- element around the inlines of a line in a LineBlock +tgNameLineItem :: Text +tgNameLineItem = "line" diff --git a/test/Tests/XML.hs b/test/Tests/XML.hs new file mode 100644 index 000000000..71175917c --- /dev/null +++ b/test/Tests/XML.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE OverloadedStrings #-} +{- | +-- Module : Tests.XML +-- Copyright : Copyright (C) 2025- Massimiliano Farinella and John MacFarlane +-- License : GNU GPL, version 2 or above +-- +-- Maintainer : Massimiliano Farinella +-- Stability : WIP +-- Portability : portable +Runs a roundtrip conversion of an AST trough the XML format: +- first from AST to XML (XML Writer), +- then back to AST (XML Reader), +- and checks that the two ASTs are the same +-} +module Tests.XML (tests) where + +import Control.Monad ((>=>)) +import Test.Tasty (TestTree) +import Test.Tasty.QuickCheck +import Tests.Helpers +import Text.Pandoc +import Text.Pandoc.Arbitrary () + +p_xml_roundtrip :: Pandoc -> Bool +p_xml_roundtrip d = d == purely (writeXML def {writerTemplate = Just mempty} >=> readXML def) d + +tests :: [TestTree] +tests = [testProperty "p_xml_roundtrip" p_xml_roundtrip] \ No newline at end of file diff --git a/test/test-pandoc.hs b/test/test-pandoc.hs index 2f53c16be..6c6c2d1d4 100644 --- a/test/test-pandoc.hs +++ b/test/test-pandoc.hs @@ -50,6 +50,7 @@ import qualified Tests.Writers.RST import qualified Tests.Writers.AnnotatedTable import qualified Tests.Writers.TEI import qualified Tests.Writers.Markua +import qualified Tests.XML import qualified Tests.MediaBag import Text.Pandoc.Shared (inDirectory) @@ -59,6 +60,7 @@ tests pandocPath = testGroup "pandoc tests" , testGroup "Old" (Tests.Old.tests pandocPath) , testGroup "Shared" Tests.Shared.tests , testGroup "MediaBag" Tests.MediaBag.tests + , testGroup "XML" Tests.XML.tests , testGroup "Writers" [ testGroup "Native" Tests.Writers.Native.tests , testGroup "ConTeXt" Tests.Writers.ConTeXt.tests diff --git a/tools/pandoc-xml.dtd b/tools/pandoc-xml.dtd new file mode 100644 index 000000000..e787596ed --- /dev/null +++ b/tools/pandoc-xml.dtd @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/pandoc-xml.rnc b/tools/pandoc-xml.rnc new file mode 100644 index 000000000..59043d650 --- /dev/null +++ b/tools/pandoc-xml.rnc @@ -0,0 +1,250 @@ +# A RELAX NG schema for Pandoc XML format. +# Copyright : Copyright (C) 2025- Massimiliano Farinella +# License : GNU GPL, version 2 or above +# Maintainer : Massimiliano Farinella +# +# This is a RELAX NG schema for the XML representation of Pandoc AST. +# It's an equivalent of native and JSON formats, but modeled as XML. +# You can use this schema to validate Pandoc XML documents. +# It's translated from pandoc-xml.dtd with the "Trang" software by James Clark, +# and adjusted manually to add some constraints: +# - elements with Attr can have arbitrary attributes (this is not possible with a DTD) +# - Header's "level", OrderedList's "start" and Cell's "rowspan" and "colspan" attributes +# must be a positive integer and are equal to 1 if not specified +# - column widths in ColSpec must be between 0 and 1 (inclusive, with 0=ColWidthDefault) +# - the "count" attribute in the "" element must be positive and equal to 1 if not specified + +namespace a = "http://relaxng.org/ns/compatibility/annotations/1.0" + +Pandoc = element Pandoc { attlist_Pandoc, meta, blocks } +attlist_Pandoc &= attribute api-version { text } +block = + Para + | Plain + | Header + | Div + | BlockQuote + | HorizontalRule + | BulletList + | OrderedList + | DefinitionList + | Table + | Figure + | LineBlock + | CodeBlock + | RawBlock +inline_element = + Str + | Space + | Emph + | Strong + | Underline + | Strikeout + | Superscript + | Subscript + | SmallCaps + | Quoted + | Cite + | Code + | SoftBreak + | LineBreak + | Math + | RawInline + | Link + | Image + | Note + | Span +inline = text | inline_element +attr = + attribute id { xsd:ID }?, + attribute class { text }?, + attribute * { text }* +metavalue = + MetaMap | MetaList | MetaBool | MetaString | MetaInlines | MetaBlocks +meta = element meta { attlist_meta, entry* } +attlist_meta &= empty +MetaMap = element MetaMap { attlist_MetaMap, entry* } +attlist_MetaMap &= empty +entry = element entry { attlist_entry, metavalue* } +attlist_entry &= attribute key { text } +MetaList = element MetaList { attlist_MetaList, metavalue* } +attlist_MetaList &= empty +MetaBool = element MetaBool { attlist_MetaBool, empty } +attlist_MetaBool &= attribute value { "true" | "false" } +MetaString = element MetaString { attlist_MetaString, text } +attlist_MetaString &= empty +MetaInlines = element MetaInlines { attlist_MetaInlines, inline* } +attlist_MetaInlines &= empty +MetaBlocks = element MetaBlocks { attlist_MetaBlocks, block* } +attlist_MetaBlocks &= empty +blocks = element blocks { attlist_blocks, block* } +attlist_blocks &= empty +Para = element Para { attlist_Para, inline* } +attlist_Para &= empty +Plain = element Plain { attlist_Plain, inline* } +attlist_Plain &= empty +Header = element Header { attlist_Header, inline* } +attlist_Header &= + [ a:defaultValue = "1" ] attribute level { xsd:positiveInteger }?, + attr +Div = element Div { attlist_Div, block* } +attlist_Div &= attr +BlockQuote = element BlockQuote { attlist_BlockQuote, block* } +attlist_BlockQuote &= empty +HorizontalRule = + element HorizontalRule { attlist_HorizontalRule, empty } +attlist_HorizontalRule &= empty +BulletList = element BulletList { attlist_BulletList, item+ } +attlist_BulletList &= empty +OrderedList = element OrderedList { attlist_OrderedList, item+ } +attlist_OrderedList &= + [ a:defaultValue = "1" ] attribute start { xsd:positiveInteger }?, + [ a:defaultValue = "DefaultStyle" ] + attribute number-style { + "DefaultStyle" + | "Example" + | "Decimal" + | "LowerRoman" + | "UpperRoman" + | "LowerAlpha" + | "UpperAlpha" + }?, + [ a:defaultValue = "DefaultDelim" ] + attribute number-delim { + "DefaultDelim" | "Period" | "OneParen" | "TwoParens" + }? +DefinitionList = + element DefinitionList { attlist_DefinitionList, item+ } +attlist_DefinitionList &= empty +item = + element item { + attlist_item, + (block* | (term, def+)) + } +attlist_item &= empty +term = element term { attlist_term, inline* } +attlist_term &= empty +def = element def { attlist_def, block* } +attlist_def &= empty +Table = + element Table { + attlist_Table, Caption, colspecs, TableHead, TableBody+, TableFoot + } +attlist_Table &= attr +Caption = element Caption { attlist_Caption, ShortCaption?, block* } +attlist_Caption &= empty +ShortCaption = element ShortCaption { attlist_ShortCaption, inline* } +attlist_ShortCaption &= empty +colspecs = element colspecs { attlist_colspecs, ColSpec+ } +attlist_colspecs &= empty +ColSpec = element ColSpec { attlist_ColSpec, empty } +attlist_ColSpec &= + [ a:defaultValue = "AlignDefault" ] + attribute alignment { + "AlignLeft" | "AlignRight" | "AlignCenter" | "AlignDefault" + }?, + [ a:defaultValue = "0" ] + attribute col-width { + xsd:double { minInclusive = "0" maxInclusive = "1" } + }? +TableHead = element TableHead { attlist_TableHead, Row* } +attlist_TableHead &= attr +TableFoot = element TableFoot { attlist_TableFoot, Row* } +attlist_TableFoot &= attr +TableBody = element TableBody { attlist_TableBody, header, body } +attlist_TableBody &= + [ a:defaultValue = "0" ] attribute row-head-columns { text }?, + attr +header = element header { attlist_header, Row* } +attlist_header &= empty +body = element body { attlist_body, Row* } +attlist_body &= empty +Row = element Row { attlist_Row, Cell* } +attlist_Row &= attr +Cell = element Cell { attlist_Cell, block* } +attlist_Cell &= + [ a:defaultValue = "AlignDefault" ] + attribute alignment { + "AlignLeft" | "AlignRight" | "AlignCenter" | "AlignDefault" + }?, + [ a:defaultValue = "1" ] attribute row-span { xsd:positiveInteger }?, + [ a:defaultValue = "1" ] attribute col-span { xsd:positiveInteger }?, + attr +Figure = element Figure { attlist_Figure, Caption, block* } +attlist_Figure &= attr +LineBlock = element LineBlock { attlist_LineBlock, line+ } +attlist_LineBlock &= empty +line = element line { attlist_line, inline* } +attlist_line &= empty +CodeBlock = element CodeBlock { attlist_CodeBlock, text } +attlist_CodeBlock &= attr +RawBlock = element RawBlock { attlist_RawBlock, text } +attlist_RawBlock &= attribute format { text } +Space = element Space { attlist_Space, empty } +attlist_Space &= + [ a:defaultValue = "1" ] attribute count { xsd:positiveInteger }? +Str = element Str { attlist_Str, empty } +attlist_Str &= [ a:defaultValue = "" ] attribute content { text }? +Emph = element Emph { attlist_Emph, inline* } +attlist_Emph &= empty +Strong = element Strong { attlist_Strong, inline* } +attlist_Strong &= empty +Underline = element Underline { attlist_Underline, inline* } +attlist_Underline &= empty +Strikeout = element Strikeout { attlist_Strikeout, inline* } +attlist_Strikeout &= empty +Superscript = element Superscript { attlist_Superscript, inline* } +attlist_Superscript &= empty +Subscript = element Subscript { attlist_Subscript, inline* } +attlist_Subscript &= empty +SmallCaps = element SmallCaps { attlist_SmallCaps, inline* } +attlist_SmallCaps &= empty +Span = element Span { attlist_Span, inline* } +attlist_Span &= attr +Quoted = element Quoted { attlist_Quoted, inline* } +attlist_Quoted &= + [ a:defaultValue = "DoubleQuote" ] + attribute quote-type { "SingleQuote" | "DoubleQuote" }? +Math = element Math { attlist_Math, text } +attlist_Math &= + [ a:defaultValue = "InlineMath" ] + attribute math-type { "DisplayMath" | "InlineMath" }? +RawInline = element RawInline { attlist_RawInline, text } +attlist_RawInline &= attribute format { text } +Cite = + element Cite { attlist_Cite, (text | citations | inline_element)* } +attlist_Cite &= empty +citations = element citations { attlist_citations, Citation+ } +attlist_citations &= empty +Citation = element Citation { attlist_Citation, prefix?, suffix? } +prefix = element prefix { attlist_prefix, inline* } +attlist_prefix &= empty +suffix = element suffix { attlist_suffix, inline* } +attlist_suffix &= empty +attlist_Citation &= + attribute id { text }?, + attribute note-num { text }?, + [ a:defaultValue = "0" ] attribute hash { text }?, + [ a:defaultValue = "AuthorInText" ] + attribute mode { + "AuthorInText" | "SuppressAuthor" | "NormalCitation" + }? +Code = element Code { attlist_Code, text } +attlist_Code &= attr +Image = element Image { attlist_Image, inline* } +attlist_Image &= + attribute title { text }?, + attribute src { text }?, + attr +Link = element Link { attlist_Link, inline* } +attlist_Link &= + attribute title { text }?, + attribute href { text }?, + attr +SoftBreak = element SoftBreak { attlist_SoftBreak, empty } +attlist_SoftBreak &= empty +LineBreak = element LineBreak { attlist_LineBreak, empty } +attlist_LineBreak &= empty +Note = element Note { attlist_Note, block* } +attlist_Note &= empty +start = Pandoc diff --git a/tools/pandoc-xml.rng b/tools/pandoc-xml.rng new file mode 100644 index 000000000..38188f781 --- /dev/null +++ b/tools/pandoc-xml.rng @@ -0,0 +1,913 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DefaultStyle + Example + Decimal + LowerRoman + UpperRoman + LowerAlpha + UpperAlpha + + + + + + + DefaultDelim + Period + OneParen + TwoParens + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AlignLeft + AlignRight + AlignCenter + AlignDefault + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AlignLeft + AlignRight + AlignCenter + AlignDefault + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SingleQuote + DoubleQuote + + + + + + + + + + + + + + + DisplayMath + InlineMath + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AuthorInText + SuppressAuthor + NormalCitation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/pandoc-xml.xsd b/tools/pandoc-xml.xsd new file mode 100644 index 000000000..01834f80f --- /dev/null +++ b/tools/pandoc-xml.xsd @@ -0,0 +1,602 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +