packages feed

pandoc-3.14: src/Text/Pandoc/Readers/Pptx/Shapes.hs

{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-partial-fields #-}
{- |
   Module      : Text.Pandoc.Readers.Pptx.Shapes
   Copyright   : © 2025 Anton Antic
   License     : GNU GPL, version 2 or above

   Maintainer  : Anton Antic <anton@everworker.ai>
   Stability   : alpha
   Portability : portable

Parsing of PPTX shapes (text boxes, images, tables, diagrams).
-}
module Text.Pandoc.Readers.Pptx.Shapes
  ( PptxShape(..)
  , PptxParagraph(..)
  , TableCell(..)
  , BulletType(..)
  , parseShapes
  , parseShape
  , parseShapeWith
  , inheritedBullets
  , shapeToBlocks
  , paragraphsToBlocks
  , isTitlePlaceholder
  , extractDrawingMLText
  ) where

import Codec.Archive.Zip (Archive, findEntryByPath, fromEntry)
import qualified Data.ByteString.Lazy as B
import Data.Char (isSpace)
import Data.List (find, groupBy)
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import qualified Data.Set as S
import qualified Data.Text as T
import Data.Text (Text)
import Text.Read (readMaybe)
import qualified Text.Pandoc.Builder as B
import Text.Pandoc.Class.PandocMonad (PandocMonad)
import qualified Text.Pandoc.Class.PandocMonad as P
import Text.Pandoc.Definition
import Text.Pandoc.Readers.OOXML.Shared
import Text.Pandoc.Readers.OOXML.Charts
import Text.Pandoc.Readers.Pptx.SmartArt
import Text.Pandoc.Shared (collapseFilePath)
import Text.Pandoc.Walk (walk)
import Text.Pandoc.XML.Light
import Text.TeXMath (writeTeX)
import Text.TeXMath.Readers.OMML (readOMML)
import qualified System.FilePath.Posix as Posix

-- | Paragraph with bullet/numbering information
data PptxParagraph = PptxParagraph
  { paraLevel   :: Int            -- Bullet level (0, 1, 2...)
  , paraBullet  :: BulletType
  , paraText    :: [Inline]
  } deriving (Show)

-- | A table cell (@a:tc@): its text, its row and column span, and whether
-- another cell's span covers it from the left (@hMerge@) or from above
-- (@vMerge@)
data TableCell = TableCell
  { tcText    :: Text
  , tcRowSpan :: Int
  , tcColSpan :: Int
  , tcHMerge  :: Bool
  , tcVMerge  :: Bool
  } deriving (Show)

tcCovered :: TableCell -> Bool
tcCovered c = tcHMerge c || tcVMerge c

-- | Bullet type
data BulletType
  = NoBullet
  | Bullet                        -- Has bullet (character detected or implicit)
  | WingdingsBullet              -- Detected via Wingdings symbol
  | Numbered ListAttributes      -- Automatic numbering (a:buAutoNum)
  deriving (Show, Eq)

-- | Shape types in PPTX slides
data PptxShape
  = PptxTextBox [PptxParagraph]         -- Parsed paragraphs with bullet info
  | PptxPicture
      { picRelId  :: Text               -- Relationship ID (lazy loading)
      , picTitle  :: Text
      , picAlt    :: Text
      }
  | PptxTable [[TableCell]]             -- Rows of cells
  | PptxDiagramRef
      { dgmDataRelId   :: Text          -- Relationship to data.xml
      , dgmLayoutRelId :: Text          -- Relationship to layout.xml
      }
  | PptxChartRef Text Text              -- Relationship to the chart, URI
  | PptxGraphic Text                    -- Placeholder for other graphics
  deriving (Show)

-- | Parse all shapes from shape tree
parseShapes :: NameSpaces -> Element -> [PptxShape]
parseShapes ns spTreeElem =
  let shapeElems = onlyElems $ elContent spTreeElem
      -- Merge parent namespaces with element namespaces
      ns' = ns <> elemToNameSpaces spTreeElem
   in mapMaybe (parseShape ns') shapeElems

-- | Parse individual shape element
parseShape :: NameSpaces -> Element -> Maybe PptxShape
parseShape = parseShapeWith (const Nothing)

-- | Parse a shape, given the bullet its paragraphs inherit at each level
-- (0 for the first) when they don't set one themselves
parseShapeWith :: (Int -> Maybe BulletType) -> NameSpaces -> Element
               -> Maybe PptxShape
parseShapeWith inherited ns el
  -- Text box: <p:sp> with <p:txBody>
  -- A slide number placeholder only holds the number PowerPoint shows
  | isSlideNumberPlaceholder ns el = Nothing
  | isElem ns "p" "sp" el =
      case findChildByName ns "p" "txBody" el of
        Just txBody ->
          let paras = parseParagraphs inherited ns txBody
           in if null paras
              then Nothing
              else Just $ PptxTextBox paras
        Nothing -> Nothing

  -- Picture: <p:pic>
  | isElem ns "p" "pic" el = do
      nvPicPr <- findChildByName ns "p" "nvPicPr" el
      cNvPr <- findChildByName ns "p" "cNvPr" nvPicPr

      let title = maybe "" id $ findAttr (unqual "name") cNvPr
          alt = maybe "" id $ findAttr (unqual "descr") cNvPr

      -- Get blip relationship ID
      blipFill <- findChildByName ns "p" "blipFill" el
      blip <- findChildByName ns "a" "blip" blipFill
      relId <- findAttrByName ns "r" "embed" blip

      return $ PptxPicture relId title alt

  -- GraphicFrame: table or diagram
  | isElem ns "p" "graphicFrame" el =
      case findChildByName ns "a" "graphic" el >>=
           findChildByName ns "a" "graphicData" of
        Nothing -> Nothing
        Just graphicData ->
          case findAttr (unqual "uri") graphicData of
            Nothing -> Just $ PptxGraphic "no-uri"
            Just uri ->
              if "table" `T.isInfixOf` uri
                then
                  -- Table
                  case findChildByName ns "a" "tbl" graphicData of
                    Just tbl ->
                      let rows = parseTableRows ns tbl
                       in Just $ PptxTable rows
                    Nothing -> Nothing
                else if "diagram" `T.isInfixOf` uri
                  then
                    -- SmartArt diagram - dgm namespace is declared inline on relIds element
                    let dgmRelIds = find (\e -> qName (elName e) == "relIds") (elChildren graphicData)
                     in case dgmRelIds of
                          Nothing -> Just $ PptxGraphic "diagram-no-relIds"
                          Just relIdsElem ->
                            -- Get r:dm and r:lo attributes (r namespace is in parent)
                            let ns' = ns <> elemToNameSpaces relIdsElem
                             in case (findAttrByName ns' "r" "dm" relIdsElem,
                                      findAttrByName ns' "r" "lo" relIdsElem) of
                                  (Just dataRelId, Just layoutRelId) ->
                                    Just $ PptxDiagramRef dataRelId layoutRelId
                                  _ -> Just $ PptxGraphic "diagram-missing-rels"
                  else if uri == chartUri
                    then
                      -- The c prefix is usually declared on c:chart itself
                      case find ((== "chart") . qName . elName)
                                (elChildren graphicData) of
                        Just chartElem
                          | Just relId <- findAttrByName
                              (ns <> elemToNameSpaces chartElem) "r" "id"
                              chartElem
                          -> Just $ PptxChartRef relId uri
                        _ -> Just $ PptxGraphic ("other: " <> uri)
                  else
                    -- Other graphic
                    Just $ PptxGraphic ("other: " <> uri)

  -- Skip other shapes for now
  | otherwise = Nothing

-- | Parse table rows (simple text extraction)
parseTableRows :: NameSpaces -> Element -> [[TableCell]]
parseTableRows ns tblElem =
  let trElems = findChildrenByName ns "a" "tr" tblElem
   in map (parseTableRow ns) trElems

parseTableRow :: NameSpaces -> Element -> [TableCell]
parseTableRow ns trElem =
  let tcElems = findChildrenByName ns "a" "tc" trElem
   in map parseCell tcElems
  where
    parseCell tcElem = TableCell
      { tcText = case findChildByName ns "a" "txBody" tcElem of
          -- Get text from txBody/a:p/a:r/a:t
          Just txBody -> extractDrawingMLText txBody
          Nothing -> ""
      , tcRowSpan = span' "rowSpan" tcElem
      , tcColSpan = span' "gridSpan" tcElem
      , tcHMerge = flag "hMerge" tcElem
      , tcVMerge = flag "vMerge" tcElem
      }
    span' name el = maybe 1 (max 1) $
      findAttr (unqual name) el >>= readMaybe . T.unpack
    flag name el = findAttr (unqual name) el `elem` [Just "1", Just "true"]

-- | Convert shape to Pandoc blocks
shapeToBlocks :: PandocMonad m => Archive -> [(Text, Text)] -> PptxShape -> m [Block]
shapeToBlocks _archive rels (PptxTextBox paras) =
  return $ resolveLinks rels $ paragraphsToBlocks paras
shapeToBlocks archive rels (PptxPicture relId title alt) = do
  -- Resolve relationship to get media path
  case lookup relId rels of
    Nothing -> return []  -- Image not found
    Just target -> do
      let mediaPath = resolveMediaPath target

      -- Load image bytes and add to MediaBag
      case loadMediaFromArchive archive mediaPath of
        Nothing -> return []
        Just mediaBytes -> do
          P.insertMedia (T.unpack mediaPath) Nothing mediaBytes

          let altText = if T.null alt then [] else [Str alt]
          return [Para [Image nullAttr altText (mediaPath, title)]]

shapeToBlocks _archive _rels (PptxTable rows) =
  case spannedRows rows of
    [] -> return []
    (headerRow:bodyRows) -> do
      let makeCell (TableCell text rs cs _ _) =
            Cell nullAttr AlignDefault (RowSpan rs) (ColSpan cs)
                 [Plain (B.toList (B.text text))]
          visible = map makeCell . filter (not . tcCovered)
          headerCells = visible headerRow
          bodyCells = map visible bodyRows
          caption = Caption Nothing []
          colSpec = replicate (length headerRow) (AlignDefault, ColWidthDefault)
          headerRow' = Row nullAttr headerCells
          bodyRows' = map (Row nullAttr) bodyCells
          thead = TableHead nullAttr [headerRow']
          tbody = [TableBody nullAttr 0 [] bodyRows']
          tfoot = TableFoot nullAttr []
      return [Table nullAttr caption colSpec thead tbody tfoot]

shapeToBlocks archive rels (PptxDiagramRef dataRelId layoutRelId) = do
  -- Parse SmartArt diagram
  case parseDiagram archive rels dataRelId layoutRelId of
    Left err -> do
      -- Failed to parse diagram, return placeholder
      return [Para [Str $ "[Diagram parse error: " <> err <> "]"]]
    Right diagram ->
      return $ diagramToBlocks diagram
shapeToBlocks archive rels (PptxChartRef relId uri) =
  -- A chart whose part can't be read keeps the placeholder
  return $ either (const [Para [Str $ "[Graphic: other: " <> uri <> "]"]])
                  chartToBlocks $ do
    target <- maybe (Left $ "Relationship not found: " <> relId) Right $
              lookup relId rels
    readChart archive $ case T.unpack target of
      '/':path -> path
      path -> collapseFilePath ("ppt/slides" Posix.</> path)
shapeToBlocks _archive _rels (PptxGraphic text) =
  -- Placeholder for other graphics (charts, etc.)
  return [Para [Str $ "[Graphic: " <> text <> "]"]]

-- | Make the spans of a table's cells fit pandoc's table model. Each row
-- has a cell for every grid column, with the cells covered by a span
-- marked. As a cell can't span the header row and the body, the header
-- row's spans are kept to that row, and the body cells they covered
-- become empty cells of their own. Row spans are clipped to the rows
-- there are.
spannedRows :: [[TableCell]] -> [[TableCell]]
spannedRows [] = []
spannedRows (header:body) =
  map (\c -> c{ tcRowSpan = 1 }) header : go headerSpanCols (length body) body
  where
    headerSpanCols = S.fromList
      [ col' | (col, c) <- zip [0 :: Int ..] header
             , tcRowSpan c > 1, not (tcCovered c)
             , col' <- [col .. col + tcColSpan c - 1] ]
    -- cols: the columns still covered by a span from the header row;
    -- left: the number of rows from this one to the last
    go _ _ [] = []
    go cols left (row:rows) =
      let cells = zip [0 ..] row
          fix (col, c)
            | col `S.member` cols && tcVMerge c =
                TableCell "" 1 1 False False
            | otherwise = c{ tcRowSpan = min (tcRowSpan c) left }
          stillCovered col = maybe False tcVMerge (lookup col cells)
       in map fix cells : go (S.filter stillCovered cols) (left - 1) rows

-- | Resolve media path (handle relative paths)
resolveMediaPath :: Text -> Text
resolveMediaPath target =
  if "../media/" `T.isPrefixOf` target
    then "ppt/media/" <> T.drop 9 target  -- "../media/" = 9 chars
    else if "media/" `T.isPrefixOf` target
      then "ppt/" <> target
      else target

-- | Load media file from archive
loadMediaFromArchive :: Archive -> Text -> Maybe B.ByteString
loadMediaFromArchive archive path =
  case findEntryByPath (T.unpack path) archive of
    Just entry -> Just $ fromEntry entry
    Nothing -> Nothing

-- | Parse paragraphs from text box
parseParagraphs :: (Int -> Maybe BulletType) -> NameSpaces -> Element
                -> [PptxParagraph]
parseParagraphs inherited ns txBody =
  let pElems = findChildrenByName ns "a" "p" txBody
   in map (parseParagraph inherited ns) pElems

-- | Parse individual paragraph
parseParagraph :: (Int -> Maybe BulletType) -> NameSpaces -> Element
               -> PptxParagraph
parseParagraph inherited ns pElem =
  let level = parseBulletLevel ns pElem
      bullet = detectBulletType (inherited level) ns pElem
      text = paragraphInlines pElem
   in PptxParagraph level bullet text

-- | Parse bullet level from paragraph properties
parseBulletLevel :: NameSpaces -> Element -> Int
parseBulletLevel ns pElem =
  case findChildByName ns "a" "pPr" pElem >>=
       findAttr (unqual "lvl") >>=
       (\s -> readMaybe (T.unpack s) :: Maybe Int) of
    Just lvl -> lvl
    Nothing -> 0  -- Default to level 0

-- | Detect bullet type: the paragraph's own, or else the one it inherits
detectBulletType :: Maybe BulletType -> NameSpaces -> Element -> BulletType
detectBulletType inherited ns pElem =
  case findChildByName ns "a" "pPr" pElem >>= explicitBullet of
    Just bullet -> bullet
    Nothing
      -- Check for Wingdings symbol (common in PowerPoint)
      | hasWingdingsSymbol ns pElem -> WingdingsBullet
      | otherwise -> fromMaybe NoBullet inherited

-- | The bullet that paragraph properties (@a:pPr@ or @a:lvlNpPr@) set,
-- if they set one
explicitBullet :: Element -> Maybe BulletType
explicitBullet pPr = listToMaybe (mapMaybe bullet (elChildren pPr))
  where
    bullet el = case qName (elName el) of
      "buChar"    -> Just Bullet
      "buBlip"    -> Just Bullet
      "buNone"    -> Just NoBullet
      "buAutoNum" -> Just (Numbered (numbering el))
      _           -> Nothing
    numbering el =
      let scheme = fromMaybe "arabicPeriod" (findAttr (unqual "type") el)
          start = fromMaybe 1 $
                  findAttr (unqual "startAt") el >>= readMaybe . T.unpack
          style | "arabic" `T.isPrefixOf` scheme = Decimal
                | "alphaLc" `T.isPrefixOf` scheme = LowerAlpha
                | "alphaUc" `T.isPrefixOf` scheme = UpperAlpha
                | "romanLc" `T.isPrefixOf` scheme = LowerRoman
                | "romanUc" `T.isPrefixOf` scheme = UpperRoman
                | otherwise = DefaultStyle
          delim | "ParenBoth" `T.isSuffixOf` scheme = TwoParens
                | "ParenR" `T.isSuffixOf` scheme = OneParen
                | "Period" `T.isSuffixOf` scheme = Period
                | otherwise = DefaultDelim
       in (start, style, delim)

-- | The bullet a shape's paragraphs inherit at each level (0 for the
-- first), from the shape's own list style, then the matching placeholder
-- on the slide layout and on the slide master, then the master's text
-- style for that kind of placeholder (or for other text).
inheritedBullets :: Maybe Element  -- ^ Slide layout
                 -> Maybe Element  -- ^ Slide master
                 -> Element        -- ^ Shape
                 -> Int -> Maybe BulletType
inheritedBullets layout master shape level =
  listToMaybe $ mapMaybe (>>= explicitBullet) $
    [lstLevel shape] ++
    case placeholder shape of
      Just (phType, phIdx) ->
        [ layout >>= findPlaceholder phType phIdx >>= lstLevel
        , master >>= findPlaceholder (masterType phType) Nothing >>= lstLevel
        , textStyle (styleFor phType) ]
      Nothing -> [textStyle "otherStyle"]
  where
    lvlName = "lvl" <> T.pack (show (level + 1)) <> "pPr"
    child name el = find ((== name) . qName . elName) (elChildren el)
    lstLevel sp = child "txBody" sp >>= child "lstStyle" >>= child lvlName
    textStyle name = master >>= child "txStyles" >>= child name >>=
                     child lvlName
    placeholder sp = do
      ph <- child "nvSpPr" sp >>= child "nvPr" >>= child "ph"
      return ( fromMaybe "obj" (findAttr (unqual "type") ph)
             , findAttr (unqual "idx") ph )
    -- A placeholder is matched by index if it has one, else by type
    findPlaceholder phType phIdx root = do
      tree <- child "cSld" root >>= child "spTree"
      let phs = [ (sp, p) | sp <- elChildren tree
                          , Just p <- [placeholder sp] ]
          byIdx = [ sp | Just i <- [phIdx], (sp, (_, j)) <- phs, j == Just i ]
          byType = [ sp | (sp, (t, _)) <- phs, masterType t == phType ||
                                              t == phType ]
      listToMaybe (byIdx ++ byType)
    masterType t = if t `elem` ["obj", "body"] then "body" else t
    styleFor t
      | t `elem` ["title", "ctrTitle"] = "titleStyle"
      | t `elem` ["obj", "body", "subTitle"] = "bodyStyle"
      | otherwise = "otherStyle"

-- | Check if paragraph starts with Wingdings symbol
hasWingdingsSymbol :: NameSpaces -> Element -> Bool
hasWingdingsSymbol ns pElem =
  let runs = findChildrenByName ns "a" "r" pElem
      checkRun r = case findChildByName ns "a" "rPr" r >>=
                        findChildByName ns "a" "sym" of
                     Just symElem ->
                       case findAttr (unqual "typeface") symElem of
                         Just typeface -> "Wingdings" `T.isInfixOf` typeface
                         Nothing -> False
                     Nothing -> False
   in any checkRun runs

-- | Inlines of a paragraph: the text of its runs and fields with their
-- bold, italic, superscript and subscript, its line breaks, its
-- hyperlinks, and its equations (@a14:m@) as math. A hyperlink's target
-- is a relationship id at this point, which 'resolveLinks' replaces.
paragraphInlines :: Element -> [Inline]
paragraphInlines pElem =
  B.toList $ foldMap linked $ groupBy (\a b -> fst a == fst b) $
    concatMap piece (elChildren pElem)
  where
    piece el = case qName (elName el) of
      "r"   -> [(runLink el, Left (runFormat el, runText el))]
      "fld" -> [(runLink el, Left (runFormat el, runText el))]
      "br"  -> [(Nothing, Right B.linebreak)]
      "m"   -> [(Nothing, Right (foldMap mathInlines (elChildren el)))]
      _     -> []
    linked grp =
      let inls = foldMap formatted $ groupBy sameFormat (map snd grp)
       in case grp of
            (Just relId, _) : _ ->
              B.linkWith ("", [], [(linkRelAttr, relId)]) "" "" inls
            _ -> inls
    sameFormat (Left (f, _)) (Left (g, _)) = f == g
    sameFormat _ _ = False
    -- Runs with the same format are joined before it is applied, so
    -- that adjacent bold runs make one strong span.
    formatted pieces@(Left (fmt, _) : _) =
      applyFormat fmt (T.concat [t | Left (_, t) <- pieces])
    formatted pieces = mconcat [ils | Right ils <- pieces]

-- | The format of a run: bold, italic, and its baseline shift (positive
-- for superscript, negative for subscript)
type RunFormat = (Bool, Bool, Int)

runFormat :: Element -> RunFormat
runFormat run =
  case find ((== "rPr") . qName . elName) (elChildren run) of
    Nothing -> (False, False, 0)
    Just rPr ->
      ( flag "b" rPr, flag "i" rPr
      , fromMaybe 0 $
          findAttr (unqual "baseline") rPr >>= readMaybe . T.unpack )
  where
    flag name el = findAttr (unqual name) el `elem` [Just "1", Just "true"]

runText :: Element -> Text
runText = T.concat . map strContent .
          filter ((== "t") . qName . elName) . elChildren

-- | Text with a run format. Spaces at the edges stay outside the
-- formatting, as Markdown emphasis can't start or end with a space.
applyFormat :: RunFormat -> Text -> B.Inlines
applyFormat (bold, italic, baseline) txt
  | not (bold || italic || baseline /= 0) || T.null core = B.text txt
  | otherwise = edge leading <> wrap (B.text core) <> edge trailing
  where
    isBlank = (`elem` (" \t\r\n" :: String))
    core = T.dropAround isBlank txt
    leading = T.takeWhile isBlank txt
    trailing = T.takeWhileEnd isBlank txt
    edge t = if T.null t then mempty else B.space
    wrap = (if bold then B.strong else id) .
           (if italic then B.emph else id) .
           (if baseline > 0 then B.superscript
              else if baseline < 0 then B.subscript else id)

-- | The relationship id of a run's hyperlink (@a:hlinkClick@). Links
-- that act within the presentation, such as a jump to another slide,
-- carry an @action@ and are not kept.
runLink :: Element -> Maybe Text
runLink run = do
  rPr <- find ((== "rPr") . qName . elName) (elChildren run)
  link <- find ((== "hlinkClick") . qName . elName) (elChildren rPr)
  case findAttr (unqual "action") link of
    Just _ -> Nothing
    Nothing -> fmap attrVal $ find isRelId (elAttribs link)
  where
    isRelId a = qName (attrKey a) == "id" && qURI (attrKey a) == Just relNs
    relNs =
      "http://schemas.openxmlformats.org/officeDocument/2006/relationships"

-- | The attribute that marks a hyperlink whose target is still a
-- relationship id
linkRelAttr :: Text
linkRelAttr = "pptx-relationship"

-- | Replace the relationship id of each hyperlink read by
-- 'paragraphInlines' with the relationship's target. A link whose
-- relationship is missing is replaced by its text.
resolveLinks :: [(Text, Text)] -> [Block] -> [Block]
resolveLinks rels = walk resolve
  where
    resolve (Link (_, _, [(key, relId)]) inls _)
      | key == linkRelAttr =
          case lookup relId rels of
            Just target -> Link nullAttr inls (target, "")
            Nothing -> Span nullAttr inls
    resolve x = x

-- | An equation (@m:oMathPara@ or @m:oMath@) as math. If texmath can't
-- read it, its text is kept.
mathInlines :: Element -> B.Inlines
mathInlines el =
  case readOMML (showElement el) of
    Right exps
      | qName (elName el) == "oMathPara" -> B.displayMath (writeTeX exps)
      | otherwise -> B.math (writeTeX exps)
    Left _ -> B.text $ T.concat $ map strContent $
                filterElementsName ((== "t") . qName) el

-- | Extract text from DrawingML element (all its paragraphs)
extractDrawingMLText :: Element -> Text
extractDrawingMLText = drawingMLText

-- | Convert paragraphs to blocks, grouping bulleted and numbered
-- paragraphs into lists. Paragraphs at a deeper level than a list item
-- go inside that item, as a nested list or as further paragraphs.
paragraphsToBlocks :: [PptxParagraph] -> [Block]
paragraphsToBlocks [] = []
paragraphsToBlocks (p:ps)
  | paraBullet p == NoBullet = Para (paraText p) : paragraphsToBlocks ps
  -- PowerPoint shows no bullet or number on an empty paragraph
  | isEmpty p = paragraphsToBlocks ps
  | otherwise =
      let (items, rest) = listItems p (p:ps)
       in listBlock (paraBullet p) items : paragraphsToBlocks rest
  where
    -- The items of a list that starts with the paragraph first, with the
    -- deeper paragraphs that follow each item
    listItems first (q:qs)
      | isEmpty q, paraBullet q /= NoBullet = listItems first qs
      | paraLevel q == paraLevel first, sameKind q first =
          let (inner, rest) = span ((> paraLevel first) . paraLevel) qs
              (items, rest') = listItems first rest
           in ((Plain (paraText q) : paragraphsToBlocks inner) : items, rest')
    listItems _ qs = ([], qs)
    sameKind a b = case (paraBullet a, paraBullet b) of
      (Numbered _, Numbered _) -> True
      (Numbered _, _) -> False
      (_, Numbered _) -> False
      (x, y) -> (x == NoBullet) == (y == NoBullet)
    listBlock (Numbered attrs) items = OrderedList attrs items
    listBlock _ items = BulletList items
    isEmpty = all blank . paraText
    blank il = case il of
      Space -> True
      SoftBreak -> True
      LineBreak -> True
      Str t -> T.all isSpace t
      _ -> False

-- | Check if shape is a slide number placeholder
isSlideNumberPlaceholder :: NameSpaces -> Element -> Bool
isSlideNumberPlaceholder ns el =
  maybe False ((== Just "sldNum") . findAttr (unqual "type")) $
    findChildByName ns "p" "nvSpPr" el >>=
    findChildByName ns "p" "nvPr" >>=
    findChildByName ns "p" "ph"

-- | Check if shape is title placeholder (also used in Slides module)
isTitlePlaceholder :: NameSpaces -> Element -> Bool
isTitlePlaceholder ns el =
  case findChildByName ns "p" "nvSpPr" el >>=
       findChildByName ns "p" "nvPr" >>=
       findChildByName ns "p" "ph" of
    Just phElem ->
      case findAttr (unqual "type") phElem of
        Just phType -> phType == "title" || phType == "ctrTitle"
        Nothing -> False
    Nothing -> False

-- | The graphic data URI of a chart
chartUri :: Text
chartUri = "http://schemas.openxmlformats.org/drawingml/2006/chart"