packages feed

pandoc-3.8.3: 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 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 qualified Data.Vector as V
import System.FilePath (splitFileName)
import Text.Pandoc.Readers.OOXML.Shared
import Text.Pandoc.Readers.Xlsx.Cells
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
  } 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)
  } deriving (Show)

-- | Individual worksheet
data XlsxSheet = XlsxSheet
  { sheetId :: SheetId
  , sheetName :: Text
  , sheetCells :: M.Map CellRef XlsxCell
  } 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
      let path = "xl/" ++ T.unpack target
      el <- loadXMLFromArchive archive path
      parseSharedStrings el
    Nothing -> Right V.empty

  -- Parse styles
  styles <- case findRelWithTarget workbookRels "styles" of
    Just (_, target) -> do
      let path = "xl/" ++ T.unpack target
      el <- loadXMLFromArchive archive path
      parseStyles el
    Nothing -> Right $ Styles V.empty

  -- Parse worksheets
  sheets <- mapM (\sheetInfo -> parseSheet archive workbookRels sharedStrings styles sheetInfo)
                 (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
      return $ T.unpack 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)

  return $ XlsxWorkbook sheetRefs

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 extractString siElems
  return $ V.fromList strings
  where
    extractString siElem =
      case find (\e -> qName (elName e) == "t") (onlyElems $ elContent siElem) of
        Just tElem -> strContent tElem
        Nothing -> getAllText siElem

-- | 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

  return $ Styles fonts

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 -> [(Text, Text)] -> SharedStrings -> Styles -> (SheetId, Text, Text) -> Either Text XlsxSheet
parseSheet archive rels sharedStrings styles (sid, name, relId) = do
  target <- maybeToEither ("Sheet relationship not found: " <> relId) $
            lookup relId rels

  let sheetPath = "xl/" ++ T.unpack target
  sheetElem <- loadXMLFromArchive archive sheetPath

  cells <- parseSheetCells sheetElem sharedStrings styles

  return $ XlsxSheet sid name cells

-- | Parse sheet cells
parseSheetCells :: Element -> SharedStrings -> Styles -> Either Text (M.Map CellRef XlsxCell)
parseSheetCells sheetElem sharedStrings styles = 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) cellElems
      return $ M.fromList [(cellRef c, c) | c <- cells]

-- | Parse individual cell
parseCell :: SharedStrings -> Styles -> Element -> Maybe XlsxCell
parseCell sharedStrings styles 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 T.null vText
                then EmptyValue
                else
                  -- Number
                  case readMaybe (T.unpack vText) of
                    Just n -> NumberValue n
                    Nothing -> TextValue vText

  -- Get formatting from style
  let (bold, italic) = case styleIdx of
        Just idx | idx >= 0 && idx < V.length (styleFonts styles) ->
          let font = styleFonts styles V.! idx
           in (fontBold font, fontItalic font)
        _ -> (False, False)

  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"

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

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

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