packages feed

pandoc-3.14: src/Text/Pandoc/Readers/Xlsx/Parse.hs

{-# LANGUAGE OverloadedStrings #-}
{- |
   Module      : Text.Pandoc.Readers.Xlsx.Parse
   Copyright   : © 2025 Anton Antic
   License     : GNU GPL, version 2 or above

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

Parsing of XLSX archive to intermediate representation.
-}
module Text.Pandoc.Readers.Xlsx.Parse
  ( Xlsx(..)
  , XlsxWorkbook(..)
  , XlsxSheet(..)
  , SheetId(..)
  , SharedStrings
  , Styles(..)
  , FontInfo(..)
  , archiveToXlsx
  ) where

import Codec.Archive.Zip (Archive, Entry, findEntryByPath, fromEntry)
import Control.Monad (join)
import Data.Char (toLower)
import Data.List (find)
import qualified Data.Map.Strict as M
import Data.Maybe (mapMaybe, fromMaybe)
import qualified Data.Text as T
import qualified Data.Text.Lazy.Encoding as TL
import Data.Text (Text)
import Data.Time.Calendar (Day, addDays, fromGregorian, showGregorian)
import qualified Data.Vector as V
import System.FilePath (splitFileName)
import qualified System.FilePath.Posix as Posix
import Text.Pandoc.Readers.OOXML.Charts (Chart, readChart)
import Text.Pandoc.Readers.OOXML.Shared
import Text.Pandoc.Readers.Xlsx.Cells
import Text.Pandoc.Shared (collapseFilePath)
import Text.Pandoc.XML.Light
import Text.Read (readMaybe)

-- | Sheet identifier
newtype SheetId = SheetId Int deriving (Show, Eq, Ord)

-- | Shared strings table (Vector for O(1) lookup)
type SharedStrings = V.Vector Text

-- | Font information
data FontInfo = FontInfo
  { fontBold :: Bool
  , fontItalic :: Bool
  , fontUnderline :: Bool
  } deriving (Show)

-- | Style information
data Styles = Styles
  { styleFonts :: V.Vector FontInfo
  , styleCellFonts :: V.Vector Int
    -- ^ For each cell format (@cellXfs@), the index of its font
  , styleDateFormats :: V.Vector (Maybe DateFormat)
    -- ^ For each cell format (@cellXfs@), what it shows of a date
  } deriving (Show)

-- | What a date or time number format shows
data DateFormat = DateFormat
  { dateShowsDate :: Bool
  , dateShowsTime :: Bool
  , dateShowsSeconds :: Bool
  } deriving (Show)

-- | Complete XLSX document
data Xlsx = Xlsx
  { xlsxWorkbook :: XlsxWorkbook
  , xlsxSheets :: [XlsxSheet]
  , xlsxSharedStrings :: SharedStrings
  , xlsxStyles :: Styles
  } deriving (Show)

-- | Workbook information
data XlsxWorkbook = XlsxWorkbook
  { workbookSheetNames :: [(SheetId, Text, Text)]  -- (id, name, relId)
  , workbookDate1904 :: Bool  -- ^ Dates count from 1904 rather than 1900
  } deriving (Show)

-- | Individual worksheet
data XlsxSheet = XlsxSheet
  { sheetId :: SheetId
  , sheetName :: Text
  , sheetCells :: M.Map CellRef XlsxCell
  , sheetMerges :: [(CellRef, CellRef)]
    -- ^ Merged ranges, as their top-left and bottom-right cells
  , sheetCharts :: [Chart]  -- ^ In the order they are drawn
  } deriving (Show)

-- | Parse XLSX archive
archiveToXlsx :: Archive -> Either Text Xlsx
archiveToXlsx archive = do
  -- Find and parse workbook.xml
  workbookPath <- getWorkbookXmlPath archive
  workbookElem <- loadXMLFromArchive archive workbookPath
  workbook <- parseWorkbook workbookElem
    `addContext` ("Parsing workbook.xml from: " <> T.pack workbookPath)

  -- Load workbook relationships
  workbookRels <- loadRelationships archive (relsPathFor workbookPath)

  -- Parse shared strings (look for sharedStrings relationship)
  sharedStrings <- case findRelWithTarget workbookRels "sharedStrings" of
    Just (_, target) -> do
      el <- loadXMLFromArchive archive (resolveTarget workbookPath target)
      parseSharedStrings el
    Nothing -> Right V.empty

  -- Parse styles
  styles <- case findRelWithTarget workbookRels "styles" of
    Just (_, target) -> do
      el <- loadXMLFromArchive archive (resolveTarget workbookPath target)
      parseStyles el
    Nothing -> Right $ Styles V.empty V.empty V.empty

  -- Parse worksheets
  sheets <- mapM (parseSheet archive workbookPath workbookRels sharedStrings
                    styles (workbookDate1904 workbook))
                 (workbookSheetNames workbook)

  return $ Xlsx workbook sheets sharedStrings styles

-- | Find workbook.xml via root relationships
getWorkbookXmlPath :: Archive -> Either Text FilePath
getWorkbookXmlPath archive = do
  relsEntry <- maybeToEither "Missing _rels/.rels" $
               findEntryByPath "_rels/.rels" archive
  relsElem <- parseXMLFromEntry relsEntry

  let relElems = onlyElems $ elContent relsElem
  case find isOfficeDocRel relElems of
    Nothing -> Left "No workbook.xml relationship found"
    Just rel -> do
      target <- maybeToEither "Missing Target" $ findAttr (unqual "Target") rel
      -- The root relationships belong to the package itself.
      return $ resolveTarget "" target
  where
    isOfficeDocRel el =
      case (findAttr (unqual "Type") el, findAttr (unqual "Target") el) of
        (Just relType, Just target) ->
          "officeDocument" `T.isInfixOf` relType && "workbook" `T.isInfixOf` target
        _ -> False

-- | Parse workbook.xml
parseWorkbook :: Element -> Either Text XlsxWorkbook
parseWorkbook wbElem = do
  let ns = elemToNameSpaces wbElem

  -- Find sheets element (match by local name only)
  sheets <- maybeToEither "Missing <sheets>" $
            find (\e -> qName (elName e) == "sheets") (onlyElems $ elContent wbElem)

  let sheetElems = filter (\e -> qName (elName e) == "sheet") (onlyElems $ elContent sheets)
  sheetRefs <- mapM (parseSheetRef ns) (zip [1..] sheetElems)

  let date1904 = maybe False (`elem` ["1", "true"]) $
        find (\e -> qName (elName e) == "workbookPr") (elChildren wbElem)
          >>= findAttr (unqual "date1904")

  return $ XlsxWorkbook sheetRefs date1904

parseSheetRef :: NameSpaces -> (Int, Element) -> Either Text (SheetId, Text, Text)
parseSheetRef ns (idx, sheetElem) = do
  let name = fromMaybe ("Sheet" <> T.pack (show idx)) $
             findAttr (unqual "name") sheetElem
  relId <- maybeToEither "Missing r:id" $
           findAttrByName ns "r" "id" sheetElem
  return (SheetId idx, name, relId)

-- | Parse shared strings
parseSharedStrings :: Element -> Either Text SharedStrings
parseSharedStrings sstElem = do
  let siElems = filter (\e -> qName (elName e) == "si") (onlyElems $ elContent sstElem)
      strings = map stringItemText siElems
  return $ V.fromList strings

-- | Text of a string item (@si@ in the shared strings, @is@ in an inline
-- string cell): its @t@, or the @t@ of its rich-text runs (@r@)
-- concatenated as written. Phonetic runs (@rPh@) are skipped.
stringItemText :: Element -> Text
stringItemText el = T.concat
  [ strContent t
  | child <- elChildren el
  , t <- case qName (elName child) of
           "t" -> [child]
           "r" -> filter (\e -> qName (elName e) == "t") (elChildren child)
           _   -> []
  ]

-- | Parse styles (fonts only for MVP)
parseStyles :: Element -> Either Text Styles
parseStyles stylesElem = do
  -- Parse fonts (match by local name)
  let fontsElem = find (\e -> qName (elName e) == "fonts") (onlyElems $ elContent stylesElem)
      fontElems = maybe [] (\fe -> filter (\e -> qName (elName e) == "font") (onlyElems $ elContent fe)) fontsElem
      fonts = V.fromList $ map (parseFont mempty) fontElems

      -- A cell's style index (@s@) refers to a cell format in <cellXfs>,
      -- which refers to its font by @fontId@ and its number format by
      -- @numFmtId@.
      children name el = filter (\e -> qName (elName e) == name) (elChildren el)
      cellFormats =
        [ xf
        | cellXfs <- children "cellXfs" stylesElem
        , xf <- children "xf" cellXfs
        ]
      cellFonts = V.fromList
        [ fromMaybe 0 $ findAttr (unqual "fontId") xf >>= readMaybe . T.unpack
        | xf <- cellFormats
        ]

      -- Number formats: custom ones from <numFmts>, by id
      customFormats = M.fromList
        [ (fmtId, code)
        | numFmts <- children "numFmts" stylesElem
        , numFmt <- children "numFmt" numFmts
        , Just fmtId <- [findAttr (unqual "numFmtId") numFmt >>=
                           readMaybe . T.unpack]
        , Just code <- [findAttr (unqual "formatCode") numFmt]
        ]
      xfDateFormat xf = do
        fmtId <- findAttr (unqual "numFmtId") xf >>= readMaybe . T.unpack
        maybe (builtinDateFormat fmtId) customDateFormat
          (M.lookup fmtId customFormats)
      dateFormats = V.fromList $ map xfDateFormat cellFormats

  return $ Styles fonts cellFonts dateFormats

-- | The built-in number formats that show dates or times. Format 46,
-- @[h]:mm:ss@, shows a duration rather than a time of day.
builtinDateFormat :: Int -> Maybe DateFormat
builtinDateFormat fmtId
  | fmtId >= 14 && fmtId <= 17 = Just $ DateFormat True False False
  | fmtId == 18 || fmtId == 20 = Just $ DateFormat False True False
  | fmtId == 19 || fmtId == 21 || fmtId == 45 || fmtId == 47
                               = Just $ DateFormat False True True
  | fmtId == 22                = Just $ DateFormat True True False
  | otherwise                  = Nothing

-- | Whether a custom format code shows a date or a time: whether its
-- first section has a date or time token (y, m, d, h, s) outside quoted
-- text, escaped characters and brackets. A bracketed elapsed-time token
-- such as @[h]@ makes it a duration, which isn't treated as a date.
customDateFormat :: Text -> Maybe DateFormat
customDateFormat code
  | elapsed = Nothing
  | showsDate || showsTime = Just $ DateFormat showsDate showsTime hasS
  | otherwise = Nothing
  where
    (tokens, brackets) = scan (T.unpack (T.takeWhile (/= ';') code)) [] []
    has c = c `elem` tokens
    hasS = has 's'
    showsTime = has 'h' || hasS
    showsDate = has 'y' || has 'd' || (has 'm' && not showsTime)
    elapsed = any (\b -> not (null b) && all (`elem` ("hms" :: String)) b)
                  brackets
    -- Collect the letters outside quotes, escapes and brackets, and the
    -- contents of brackets.
    scan [] ts bs = (ts, bs)
    scan ('"':cs) ts bs = scan (drop 1 (dropWhile (/= '"') cs)) ts bs
    scan ('\\':_:cs) ts bs = scan cs ts bs
    scan ('_':_:cs) ts bs = scan cs ts bs
    scan ('*':_:cs) ts bs = scan cs ts bs
    scan ('[':cs) ts bs =
      let (inside, rest) = break (== ']') cs
       in scan (drop 1 rest) ts (map toLower inside : bs)
    scan (c:cs) ts bs = scan cs (toLower c : ts) bs

-- | Render a date serial number under a date or time format, as an ISO
-- 8601 date (@2023-11-01@), time (@14:30@ or @14:30:05@), or both joined
-- by a space. Nothing if the number isn't a date the format can show:
-- negative, or before the first day of the date system for a format
-- that shows the date.
formatDate :: Bool -> DateFormat -> Double -> Maybe Text
formatDate date1904 fmt serial
  | serial < 0 = Nothing
  | otherwise = do
      dateText <- if dateShowsDate fmt
                    then (\d -> [showGregorian d]) <$> day
                    else Just []
      let timeText = [ showTime | dateShowsTime fmt ]
      return $ T.pack $ unwords (dateText ++ timeText)
  where
    seconds = round (serial * 86400) :: Integer
    (days, secs) = seconds `divMod` 86400
    day :: Maybe Day
    day
      | date1904 = Just $ addDays days (fromGregorian 1904 1 1)
      -- 1900 system: day 1 is 1900-01-01, and day 60 is the 29 February
      -- 1900 that Excel counts but that didn't exist.
      | days < 1 || days == 60 = Nothing
      | days < 60 = Just $ addDays days (fromGregorian 1899 12 31)
      | otherwise = Just $ addDays days (fromGregorian 1899 12 30)
    (h, rest) = secs `divMod` 3600
    (m, s) = rest `divMod` 60
    pad n = (if n < 10 then ('0' :) else id) (show n)
    showTime = pad h ++ ":" ++ pad m ++
               (if dateShowsSeconds fmt then ":" ++ pad s else "")

parseFont :: NameSpaces -> Element -> FontInfo
parseFont _ns fontElem =
  FontInfo
    { fontBold = any (\e -> qName (elName e) == "b") (onlyElems $ elContent fontElem)
    , fontItalic = any (\e -> qName (elName e) == "i") (onlyElems $ elContent fontElem)
    , fontUnderline = any (\e -> qName (elName e) == "u") (onlyElems $ elContent fontElem)
    }

-- | Parse individual worksheet
parseSheet :: Archive -> FilePath -> [(Text, Text)] -> SharedStrings -> Styles
           -> Bool -> (SheetId, Text, Text) -> Either Text XlsxSheet
parseSheet archive wbPath rels sharedStrings styles date1904
           (sid, name, relId) = do
  target <- maybeToEither ("Sheet relationship not found: " <> relId) $
            lookup relId rels

  let sheetPath = resolveTarget wbPath target
  sheetElem <- loadXMLFromArchive archive sheetPath

  cells <- parseSheetCells sheetElem sharedStrings styles date1904

  let merges =
        [ (from, to)
        | mergeCells <- filter (\e -> qName (elName e) == "mergeCells")
                               (elChildren sheetElem)
        , mergeCell <- filter (\e -> qName (elName e) == "mergeCell")
                              (elChildren mergeCells)
        , Just ref <- [findAttr (unqual "ref") mergeCell]
        , (fromText, toText) <- [T.breakOn ":" ref]
        , Right from <- [parseCellRef fromText]
        , Right to <- [parseCellRef (T.drop 1 toText)]
        ]

  return $ XlsxSheet sid name cells merges
           (sheetChartsOf archive sheetPath)

-- | The charts on a worksheet or chart sheet: those its drawing refers
-- to, in document order. A drawing or chart that can't be read is left
-- out.
sheetChartsOf :: Archive -> FilePath -> [Chart]
sheetChartsOf archive sheetPath = fromMaybe [] $ do
  drawingTarget <- findRelTargetByType archive (relsPathFor sheetPath)
                     "drawing"
  let drawingPath = resolveTarget sheetPath drawingTarget
  drawing <- either (const Nothing) Just $
             loadXMLFromArchive archive drawingPath
  drawingRels <- either (const Nothing) Just $
                 loadRelationships archive (relsPathFor drawingPath)
  return
    [ chart
    | chartElem <- filterElementsName ((== "chart") . qName) drawing
    , Just relId <- [lookupRelId chartElem]
    , Just chartTarget <- [lookup relId drawingRels]
    , Right chart <- [readChart archive
                        (resolveTarget drawingPath chartTarget)]
    ]
  where
    lookupRelId el = attrVal <$> find isRelId (elAttribs el)
    isRelId a = qName (attrKey a) == "id" && qURI (attrKey a) == Just
      "http://schemas.openxmlformats.org/officeDocument/2006/relationships"

-- | Find the target of the first relationship in a .rels part whose Type
-- ends with the given name, such as @drawing@
findRelTargetByType :: Archive -> FilePath -> Text -> Maybe Text
findRelTargetByType archive relsPath typeName = do
  entry <- findEntryByPath relsPath archive
  relsElem <- either (const Nothing) Just $ parseXMLFromEntry entry
  rel <- find hasType (onlyElems $ elContent relsElem)
  findAttr (unqual "Target") rel
  where
    hasType el = maybe False (("/" <> typeName) `T.isSuffixOf`) $
                 findAttr (unqual "Type") el

-- | Parse sheet cells
parseSheetCells :: Element -> SharedStrings -> Styles -> Bool
                -> Either Text (M.Map CellRef XlsxCell)
parseSheetCells sheetElem sharedStrings styles date1904 = do
  -- Find sheetData by local name
  case find (\e -> qName (elName e) == "sheetData") (onlyElems $ elContent sheetElem) of
    Nothing -> return M.empty
    Just sheetData -> do
      let rowElems = filter (\e -> qName (elName e) == "row") (onlyElems $ elContent sheetData)
          cellElems = concatMap (\r -> filter (\e -> qName (elName e) == "c") (onlyElems $ elContent r)) rowElems
          cells = mapMaybe (parseCell sharedStrings styles date1904) cellElems
      return $ M.fromList [(cellRef c, c) | c <- cells]

-- | Parse individual cell
parseCell :: SharedStrings -> Styles -> Bool -> Element -> Maybe XlsxCell
parseCell sharedStrings styles date1904 cElem = do
  -- Get cell reference
  refText <- findAttr (unqual "r") cElem
  cellRefParsed <- either (const Nothing) Just $ parseCellRef refText

  -- Get cell type (default to number if missing)
  let cellType = fromMaybe "" $ findAttr (unqual "t") cElem
      styleIdx = findAttr (unqual "s") cElem >>= readMaybe . T.unpack

  -- Get value (match by local name)
  let vElem = find (\e -> qName (elName e) == "v") (onlyElems $ elContent cElem)
      vText = maybe "" strContent vElem

  -- Parse value based on type
  let value = if cellType == "s"
              then
                -- Shared string
                case readMaybe (T.unpack vText) of
                  Just idx | idx >= 0 && idx < V.length sharedStrings ->
                    TextValue (sharedStrings V.! idx)
                  _ -> EmptyValue
              else if cellType == "inlineStr"
                then
                  -- Inline string, held in the cell itself rather than in <v>
                  maybe EmptyValue (TextValue . stringItemText) $
                    find (\e -> qName (elName e) == "is") (elChildren cElem)
              else if T.null vText
                then EmptyValue
              else if cellType == "b"
                then BoolValue (vText == "1")
                else
                  -- Number
                  case readMaybe (T.unpack vText) of
                    Just n
                      | Just txt <- dateFormat >>= \f -> formatDate date1904 f n
                      -> TextValue txt
                      | otherwise -> NumberValue n
                    Nothing -> TextValue vText
      dateFormat = join $ styleIdx >>= (styleDateFormats styles V.!?)

  -- Get formatting from style
  let font = do
        idx <- styleIdx
        fontIdx <- styleCellFonts styles V.!? idx
        styleFonts styles V.!? fontIdx
      (bold, italic) = maybe (False, False)
                         (\f -> (fontBold f, fontItalic f)) font

  return $ XlsxCell cellRefParsed value bold italic

-- Helper functions
loadXMLFromArchive :: Archive -> FilePath -> Either Text Element
loadXMLFromArchive archive path = do
  entry <- maybeToEither ("Entry not found: " <> T.pack path) $
           findEntryByPath path archive
  parseXMLFromEntry entry

parseXMLFromEntry :: Entry -> Either Text Element
parseXMLFromEntry entry =
  let lazyText = TL.decodeUtf8 $ fromEntry entry
   in parseXMLElement lazyText

loadRelationships :: Archive -> FilePath -> Either Text [(Text, Text)]
loadRelationships archive relsPath =
  case findEntryByPath relsPath archive of
    Nothing -> Right []
    Just entry -> do
      relsElem <- parseXMLFromEntry entry
      let relElems = onlyElems $ elContent relsElem
      return $ mapMaybe extractRel relElems
  where
    extractRel el = do
      relId <- findAttr (unqual "Id") el
      target <- findAttr (unqual "Target") el
      return (relId, target)

relsPathFor :: FilePath -> FilePath
relsPathFor path =
  let (dir, file) = splitFileName path
   in dir ++ "/_rels/" ++ file ++ ".rels"

-- | Resolve a relationship target to an archive path. A target starting
-- with @/@ is relative to the package root; any other target is relative
-- to the folder of the part that holds the relationship.
resolveTarget :: FilePath -> Text -> FilePath
resolveTarget source target =
  case T.unpack target of
    '/':path -> path
    path -> collapseFilePath (Posix.takeDirectory source Posix.</> path)

findRelWithTarget :: [(Text, Text)] -> Text -> Maybe (Text, Text)
findRelWithTarget rels targetName =
  find (\(_, target) -> targetName `T.isInfixOf` target) rels

maybeToEither :: Text -> Maybe a -> Either Text a
maybeToEither err Nothing = Left err
maybeToEither _ (Just x) = Right x

addContext :: Either Text a -> Text -> Either Text a
addContext (Right x) _ = Right x
addContext (Left err) ctx = Left (err <> " (context: " <> ctx <> ")")