pandoc-3.10: src/Text/Pandoc/Readers/Pptx/SmartArt.hs
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : Text.Pandoc.Readers.Pptx.SmartArt
Copyright : © 2025 Anton Antic
License : GNU GPL, version 2 or above
Maintainer : Anton Antic <anton@everworker.ai>
Stability : alpha
Portability : portable
SmartArt diagram parsing and text extraction for PPTX.
-}
module Text.Pandoc.Readers.Pptx.SmartArt
( PptxDiagram(..)
, parseDiagram
, diagramToBlocks
) where
import Codec.Archive.Zip (Archive, findEntryByPath, fromEntry)
import qualified Data.Map.Strict as M
import Data.Maybe (mapMaybe)
import qualified Data.Text as T
import qualified Data.Text.Lazy.Encoding as TL
import Data.Text (Text)
import Text.Pandoc.Definition
import Text.Pandoc.Readers.OOXML.Shared
import Text.Pandoc.XML.Light
-- | SmartArt diagram data
data PptxDiagram = PptxDiagram
{ diagramType :: Text -- Layout type (chevron, cycle, etc.)
, diagramNodes :: [(Text, [Text])] -- (nodeText, childTexts)
} deriving (Show)
-- | Parse SmartArt diagram from relationship IDs
parseDiagram :: Archive
-> [(Text, Text)] -- Slide relationships
-> Text -- data relationship ID
-> Text -- layout relationship ID
-> Either Text PptxDiagram
parseDiagram archive rels dataRelId layoutRelId = do
-- Resolve relationships to file paths
dataTarget <- maybeToEither ("Relationship not found: " <> dataRelId) $
lookup dataRelId rels
layoutTarget <- maybeToEither ("Relationship not found: " <> layoutRelId) $
lookup layoutRelId rels
-- Resolve relative paths (diagrams are in ../diagrams/ from slides/)
let dataPath = resolveDiagramPath dataTarget
layoutPath = resolveDiagramPath layoutTarget
-- Load XML files
dataElem <- loadXMLFromArchive archive dataPath
layoutElem <- loadXMLFromArchive archive layoutPath
-- Extract layout type
layoutType <- extractLayoutType layoutElem
-- Extract text nodes with hierarchy
nodes <- extractDiagramNodes dataElem
return $ PptxDiagram layoutType nodes
-- | Resolve diagram path (handle ../diagrams/ relative paths)
resolveDiagramPath :: Text -> FilePath
resolveDiagramPath target =
if "../diagrams/" `T.isPrefixOf` target
then "ppt/diagrams/" ++ T.unpack (T.drop 12 target) -- "../diagrams/" = 12 chars
else T.unpack target
-- | Load XML from archive
loadXMLFromArchive :: Archive -> FilePath -> Either Text Element
loadXMLFromArchive archive path =
case findEntryByPath path archive of
Nothing -> Left $ "File not found in archive: " <> T.pack path
Just entry ->
let xmlBytes = fromEntry entry
lazyText = TL.decodeUtf8 xmlBytes
in parseXMLElement lazyText
-- | Extract layout type from layout XML
extractLayoutType :: Element -> Either Text Text
extractLayoutType layoutElem = do
-- Look for uniqueId attribute: "urn:.../layout/chevron2"
case findAttr (unqual "uniqueId") layoutElem of
Just uid ->
-- Extract last part after last /
let layoutName = T.takeWhileEnd (/= '/') uid
in Right layoutName
Nothing ->
-- Fallback: look for title
case findChildByName ns "dgm" "title" layoutElem >>=
findAttr (unqual "val") of
Just title -> Right title
Nothing -> Right "unknown"
where
ns = elemToNameSpaces layoutElem
-- | Extract text nodes from diagram data
extractDiagramNodes :: Element -> Either Text [(Text, [Text])]
extractDiagramNodes dataElem = do
let ns = elemToNameSpaces dataElem
-- Find point list
ptLst <- maybeToEither "Missing dgm:ptLst" $
findChildByName ns "dgm" "ptLst" dataElem
let ptElems = findChildrenByName ns "dgm" "pt" ptLst
-- Build node map: modelId → text
let nodeMap = M.fromList $ mapMaybe (extractNodeText ns) ptElems
-- Parse connections
let cxnLst = findChildByName ns "dgm" "cxnLst" dataElem
connections = maybe [] (parseConnections ns) cxnLst
-- Build parent-child map
let parentMap = buildParentMap connections
-- Find parent nodes (nodes that have children)
let parentIds = M.keys parentMap
-- Build hierarchy - only show nodes that are parents
-- (children are shown under their parents)
let hierarchy = map (buildNodeWithChildren nodeMap parentMap) parentIds
-- Filter out nodes with empty text (presentation nodes)
validHierarchy = filter (\(nodeText, _) -> not $ T.null nodeText) hierarchy
return validHierarchy
-- | Extract text from a point element (returns Nothing if no text)
extractNodeText :: NameSpaces -> Element -> Maybe (Text, Text)
extractNodeText ns ptElem = do
modelId <- findAttr (unqual "modelId") ptElem
-- Extract text from dgm:t element (which contains a:p/a:r/a:t)
let text = case findChildByName ns "dgm" "t" ptElem of
Just tElem ->
-- Recursively get ALL text content from all descendants
getAllText tElem
Nothing -> ""
-- Only return nodes with actual text
if T.null (T.strip text)
then Nothing
else return (modelId, text)
-- | Connection between nodes
data Connection = Connection
{ connType :: Text
, connSrc :: Text
, connDest :: Text
} deriving (Show)
-- | Parse connections
parseConnections :: NameSpaces -> Element -> [Connection]
parseConnections ns cxnLst =
let cxnElems = findChildrenByName ns "dgm" "cxn" cxnLst
in mapMaybe (parseConnection ns) cxnElems
parseConnection :: NameSpaces -> Element -> Maybe Connection
parseConnection _ns cxnElem = do
let cxnType = maybe "" id $ findAttr (unqual "type") cxnElem -- Empty if no type
srcId <- findAttr (unqual "srcId") cxnElem
destId <- findAttr (unqual "destId") cxnElem
return $ Connection cxnType srcId destId
-- | Build parent-child map from connections
-- Use connections WITHOUT a type attribute (these are the data hierarchy)
buildParentMap :: [Connection] -> M.Map Text [Text]
buildParentMap connections =
let dataConnections = filter (\c -> T.null (connType c)) connections
in foldr addConn M.empty dataConnections
where
addConn conn m = M.insertWith (++) (connSrc conn) [connDest conn] m
-- | Build node with its children
buildNodeWithChildren :: M.Map Text Text -> M.Map Text [Text] -> Text -> (Text, [Text])
buildNodeWithChildren nodeMap parentMap nodeId =
let nodeText = M.findWithDefault "" nodeId nodeMap
childIds = M.findWithDefault [] nodeId parentMap
-- Only include children that have text
childTexts = filter (not . T.null) $
map (\cid -> M.findWithDefault "" cid nodeMap) childIds
in (nodeText, childTexts)
-- | Convert diagram to Pandoc blocks
diagramToBlocks :: PptxDiagram -> [Block]
diagramToBlocks diagram =
let nodes = diagramNodes diagram
layoutType = diagramType diagram
-- Build content blocks
contentBlocks = concatMap nodeToBlocks nodes
in [Div ("", ["smartart", layoutType], [("layout", layoutType)])
contentBlocks]
-- | Convert node to blocks
nodeToBlocks :: (Text, [Text]) -> [Block]
nodeToBlocks (nodeText, childTexts) =
if null childTexts
then [Para [Strong [Str nodeText]]]
else [ Para [Strong [Str nodeText]]
, BulletList [[Plain [Str child]] | child <- childTexts]
]
-- | Recursively extract all text from an element and its descendants
getAllText :: Element -> Text
getAllText el =
let textFromContent (Text cdata) = cdData cdata
textFromContent (Elem e) = getAllText e
textFromContent _ = ""
texts = map textFromContent (elContent el)
in T.unwords $ filter (not . T.null) texts
-- Helper functions
maybeToEither :: Text -> Maybe a -> Either Text a
maybeToEither err Nothing = Left err
maybeToEither _ (Just x) = Right x