packages feed

pandoc-3.14: src/Text/Pandoc/Readers/OOXML/Charts.hs

{-# LANGUAGE OverloadedStrings #-}
{- |
   Module      : Text.Pandoc.Readers.OOXML.Charts
   Copyright   : © 2026 Nacho García
   License     : GNU GPL, version 2 or above

   Maintainer  : Nacho García <hello@nachogarcia.dev>
   Stability   : alpha
   Portability : portable

Reading of DrawingML charts (in PPTX slides and XLSX sheets), from the
values cached in the chart part.
-}
module Text.Pandoc.Readers.OOXML.Charts
  ( Chart(..)
  , ChartSeries(..)
  , readChart
  , chartToBlocks
  ) where

import Codec.Archive.Zip (Archive, findEntryByPath, fromEntry)
import Data.List (find, nub, transpose)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import qualified Data.Text as T
import qualified Data.Text.Lazy.Encoding as TL
import Data.Text (Text)
import qualified Text.Pandoc.Builder as B
import Text.Pandoc.Definition
import Text.Pandoc.Readers.OOXML.Shared
import Text.Pandoc.XML.Light
import Text.Read (readMaybe)

-- | A chart's data, as cached in its chart part
data Chart = Chart
  { chartTitle  :: Text     -- ^ Empty if the chart has no title
  , chartTypes  :: [Text]   -- ^ Such as @bar@ or @line@, one per plot
  , chartSeries :: [ChartSeries]
  } deriving (Show)

-- | One series: its name, and its points as (category, value)
data ChartSeries = ChartSeries
  { seriesName   :: Text
  , seriesPoints :: [(Text, Text)]
  } deriving (Show)

-- | Read a chart part (@c:chartSpace@)
readChart :: Archive -> FilePath -> Either Text Chart
readChart archive path = do
  entry <- maybe (Left $ "Entry not found: " <> T.pack path) Right $
           findEntryByPath path archive
  chartSpace <- parseXMLElement (TL.decodeUtf8 (fromEntry entry))
  chart <- maybe (Left "Missing c:chart") Right $ child "chart" chartSpace
  plotArea <- maybe (Left "Missing c:plotArea") Right $
              child "plotArea" chart
  let plots = filter (("Chart" `T.isSuffixOf`) . qName . elName)
                     (elChildren plotArea)
      series = zipWith parseSeries [1..] (concatMap (children "ser") plots)
      title = maybe "" titleText (child "title" chart)
  if all (all (T.null . snd) . seriesPoints) series
     then Left "Chart has no cached values"
     else Right Chart
            { chartTitle = title
            , chartTypes = nub $ map (T.dropEnd 5 . qName . elName) plots
            , chartSeries = series
            }

-- | Text of a chart title: rich text, or a cached cell reference
titleText :: Element -> Text
titleText title =
  case child "tx" title of
    Just tx | Just rich <- child "rich" tx -> drawingMLText rich
            | otherwise -> T.unwords (cachedValues tx)
    Nothing -> ""

-- | Parse a series (@c:ser@). Its categories come from @c:cat@, or
-- @c:xVal@ in a scatter or bubble chart, and its values from @c:val@ or
-- @c:yVal@. A series without categories is numbered from 1, as in
-- PowerPoint.
parseSeries :: Int -> Element -> ChartSeries
parseSeries n ser =
  ChartSeries
    { seriesName = case child "tx" ser of
        Just tx -> T.unwords (cachedValues tx)
        Nothing -> "Series" <> T.pack (show n)
    , seriesPoints =
        [ (fromMaybe (T.pack (show (i + 1))) (M.lookup i categories), v)
        | i <- [0 .. count - 1]
        , let v = fromMaybe "" (M.lookup i values)
        ]
    }
  where
    points names = maybe (0, M.empty) cachedPoints $
      listToMaybe (mapMaybe (`child` ser) names)
    (catCount, categories) = points ["cat", "xVal"]
    (valCount, values) = points ["val", "yVal"]
    count = maximum [catCount, valCount, size values, size categories]
    size m = maybe 0 ((+ 1) . fst) (M.lookupMax m)

-- | The cached points of a data source (@c:cat@, @c:val@, ...): the
-- point count and the value of each point by index. In multi-level
-- categories only the innermost level is read.
cachedPoints :: Element -> (Int, M.Map Int Text)
cachedPoints source =
  case listToMaybe (mapMaybe cache (elChildren source)) of
    Just c ->
      ( fromMaybe 0 $ child "ptCount" c >>= findAttr (unqual "val") >>=
                        readMaybe . T.unpack
      , M.fromList
          [ (i, maybe "" strContent (child "v" pt))
          | pt <- children "pt" c
          , Just i <- [findAttr (unqual "idx") pt >>= readMaybe . T.unpack]
          ]
      )
    Nothing -> (0, M.empty)
  where
    cache el = case qName (elName el) of
      name | name `elem` ["strLit", "numLit"] -> Just el
           | name `elem` ["strRef", "numRef"] ->
               find ((`elem` ["strCache", "numCache"]) . qName . elName)
                    (elChildren el)
           | name == "multiLvlStrRef" ->
               child "multiLvlStrCache" el >>= child "lvl"
           | otherwise -> Nothing

-- | The cached values of a text source such as a series name (@c:tx@),
-- which is either a cell reference with its cache or a literal @c:v@
cachedValues :: Element -> [Text]
cachedValues tx =
  case child "v" tx of
    Just v -> [strContent v]
    Nothing -> M.elems (snd (cachedPoints tx))

-- | A chart as a table, in a @chart@ div with the chart types as its
-- @type@ attribute. Series that share their categories go in one table,
-- one column per series; otherwise each series gets its own table.
chartToBlocks :: Chart -> [Block]
chartToBlocks chart =
  [Div ("", ["chart"], [("type", T.unwords (chartTypes chart))])
       (map table groups)]
  where
    series = chartSeries chart
    categoriesOf = map fst . seriesPoints
    groups = case series of
      s:rest | all ((== categoriesOf s) . categoriesOf) rest -> [series]
      _ -> map (: []) series
    caption = if T.null (chartTitle chart)
                 then Caption Nothing []
                 else Caption Nothing [Plain (B.toList (B.text
                                               (chartTitle chart)))]
    table group =
      let cell t = Cell nullAttr AlignDefault (RowSpan 1) (ColSpan 1)
                     [Plain (B.toList (B.text t))]
          headerRow = Row nullAttr $ cell "" : map (cell . seriesName) group
          categories = maybe [] categoriesOf (listToMaybe group)
          values = transpose (map (map snd . seriesPoints) group)
          bodyRows = zipWith (\category vs -> Row nullAttr
                                              (map cell (category : vs)))
                             categories values
       in Table nullAttr caption
            (replicate (length group + 1) (AlignDefault, ColWidthDefault))
            (TableHead nullAttr [headerRow])
            [TableBody nullAttr 0 [] bodyRows]
            (TableFoot nullAttr [])

-- Chart elements are matched by local name, as the c prefix can vary.
child :: Text -> Element -> Maybe Element
child name = find ((== name) . qName . elName) . elChildren

children :: Text -> Element -> [Element]
children name = filter ((== name) . qName . elName) . elChildren