From 59b8b3ed4b6998fad87b3a29ce59ba7a5cf40c46 Mon Sep 17 00:00:00 2001 From: Anton Antich Date: Sun, 9 Nov 2025 14:07:38 +0100 Subject: [PATCH] Add `xlsx` (Microsoft Excel) as an input format. Each worksheet turns into a section containing a table. The common file `nativeDiff` has been extract from the Docx and Pptx text files and put in Tests.Helpers. --- MANUAL.txt | 2 + pandoc.cabal | 7 + src/Text/Pandoc/Readers.hs | 3 + src/Text/Pandoc/Readers/Xlsx.hs | 40 +++ src/Text/Pandoc/Readers/Xlsx/Cells.hs | 63 ++++ src/Text/Pandoc/Readers/Xlsx/Parse.hs | 296 +++++++++++++++++++ src/Text/Pandoc/Readers/Xlsx/Sheets.hs | 112 ++++++++ test/Tests/Helpers.hs | 14 + test/Tests/Readers/Docx.hs | 14 - test/Tests/Readers/Pptx.hs | 15 - test/Tests/Readers/Xlsx.hs | 48 ++++ test/test-pandoc.hs | 4 +- test/xlsx-reader/basic.native | 381 +++++++++++++++++++++++++ test/xlsx-reader/basic.xlsx | Bin 0 -> 13604 bytes 14 files changed, 969 insertions(+), 30 deletions(-) create mode 100644 src/Text/Pandoc/Readers/Xlsx.hs create mode 100644 src/Text/Pandoc/Readers/Xlsx/Cells.hs create mode 100644 src/Text/Pandoc/Readers/Xlsx/Parse.hs create mode 100644 src/Text/Pandoc/Readers/Xlsx/Sheets.hs create mode 100644 test/Tests/Readers/Xlsx.hs create mode 100644 test/xlsx-reader/basic.native create mode 100644 test/xlsx-reader/basic.xlsx diff --git a/MANUAL.txt b/MANUAL.txt index d394b2f6d..c3b657f72 100644 --- a/MANUAL.txt +++ b/MANUAL.txt @@ -283,6 +283,7 @@ header when requesting a document from a URL: - `twiki` ([TWiki markup]) - `typst` ([typst]) - `vimwiki` ([Vimwiki]) + - `xlsx` ([Excel spreadsheet][XLSX]) - `xml` (XML version of native AST) - the path of a custom Lua reader, see [Custom readers and writers] below ::: @@ -519,6 +520,7 @@ header when requesting a document from a URL: [DokuWiki markup]: https://www.dokuwiki.org/dokuwiki [ZimWiki markup]: https://zim-wiki.org/manual/Help/Wiki_Syntax.html [XWiki markup]: https://www.xwiki.org/xwiki/bin/view/Documentation/UserGuide/Features/XWikiSyntax/ +[XLSX]: https://en.wikipedia.org/wiki/Microsoft_Excel#File_formats [Vimdoc]: https://vimhelp.org/helphelp.txt.html#help-writing [TWiki markup]: https://twiki.org/cgi-bin/view/TWiki/TextFormattingRules [TikiWiki markup]: https://doc.tiki.org/Wiki-Syntax-Text#The_Markup_Language_Wiki-Syntax diff --git a/pandoc.cabal b/pandoc.cabal index f648dce11..8aa2ddd1d 100644 --- a/pandoc.cabal +++ b/pandoc.cabal @@ -440,6 +440,8 @@ extra-source-files: test/odt/odt/*.odt test/odt/markdown/*.md test/odt/native/*.native + test/xlsx-reader/*.xlsx + test/xlsx-reader/*.native test/pod-reader.pod test/vimdoc/*.markdown test/vimdoc/*.vimdoc @@ -613,6 +615,7 @@ library Text.Pandoc.Readers.Txt2Tags, Text.Pandoc.Readers.Docx, Text.Pandoc.Readers.Pptx, + Text.Pandoc.Readers.Xlsx, Text.Pandoc.Readers.ODT, Text.Pandoc.Readers.EPUB, Text.Pandoc.Readers.Muse, @@ -726,6 +729,9 @@ library Text.Pandoc.Readers.Pptx.Shapes, Text.Pandoc.Readers.Pptx.Slides, Text.Pandoc.Readers.Pptx.SmartArt, + Text.Pandoc.Readers.Xlsx.Parse, + Text.Pandoc.Readers.Xlsx.Cells, + Text.Pandoc.Readers.Xlsx.Sheets, Text.Pandoc.Readers.HTML.Parsing, Text.Pandoc.Readers.HTML.Table, Text.Pandoc.Readers.HTML.TagCategories, @@ -863,6 +869,7 @@ test-suite test-pandoc Tests.Readers.RTF Tests.Readers.Docx Tests.Readers.Pptx + Tests.Readers.Xlsx Tests.Readers.ODT Tests.Readers.Txt2Tags Tests.Readers.EPUB diff --git a/src/Text/Pandoc/Readers.hs b/src/Text/Pandoc/Readers.hs index 5f7b891e2..beec98587 100644 --- a/src/Text/Pandoc/Readers.hs +++ b/src/Text/Pandoc/Readers.hs @@ -27,6 +27,7 @@ module Text.Pandoc.Readers , readers , readDocx , readPptx + , readXlsx , readODT , readMarkdown , readCommonMark @@ -89,6 +90,7 @@ import Text.Pandoc.Readers.Creole import Text.Pandoc.Readers.DocBook import Text.Pandoc.Readers.Docx import Text.Pandoc.Readers.Pptx +import Text.Pandoc.Readers.Xlsx import Text.Pandoc.Readers.DokuWiki import Text.Pandoc.Readers.EPUB import Text.Pandoc.Readers.FB2 @@ -160,6 +162,7 @@ readers = [("native" , TextReader readNative) ,("tikiwiki" , TextReader readTikiWiki) ,("docx" , ByteStringReader readDocx) ,("pptx" , ByteStringReader readPptx) + ,("xlsx" , ByteStringReader readXlsx) ,("odt" , ByteStringReader readODT) ,("t2t" , TextReader readTxt2Tags) ,("epub" , ByteStringReader readEPUB) diff --git a/src/Text/Pandoc/Readers/Xlsx.hs b/src/Text/Pandoc/Readers/Xlsx.hs new file mode 100644 index 000000000..514dfd99e --- /dev/null +++ b/src/Text/Pandoc/Readers/Xlsx.hs @@ -0,0 +1,40 @@ +{-# LANGUAGE OverloadedStrings #-} +{- | + Module : Text.Pandoc.Readers.Xlsx + Copyright : © 2025 Anton Antic + License : GNU GPL, version 2 or above + + Maintainer : Anton Antic + Stability : alpha + Portability : portable + +Conversion of XLSX (Excel spreadsheet) documents to 'Pandoc' document. +-} +module Text.Pandoc.Readers.Xlsx (readXlsx) where + +import qualified Data.ByteString.Lazy as B +import qualified Data.Text as T +import Codec.Archive.Zip (toArchiveOrFail) +import Control.Monad.Except (throwError) +import Text.Pandoc.Class.PandocMonad (PandocMonad) +import Text.Pandoc.Definition (Pandoc(..)) +import Text.Pandoc.Error (PandocError(..)) +import Text.Pandoc.Options (ReaderOptions) +import Text.Pandoc.Readers.Xlsx.Parse (archiveToXlsx) +import Text.Pandoc.Readers.Xlsx.Sheets (xlsxToOutput) + +-- | Read XLSX file into Pandoc AST +readXlsx :: PandocMonad m => ReaderOptions -> B.ByteString -> m Pandoc +readXlsx opts bytes = + case toArchiveOrFail bytes of + Right archive -> + case archiveToXlsx archive of + Right xlsx -> do + let (meta, blocks) = xlsxToOutput opts xlsx + return $ Pandoc meta blocks + Left err -> + throwError $ PandocParseError $ "Failed to parse XLSX: " <> err + + Left err -> + throwError $ PandocParseError $ + "Failed to unpack XLSX archive: " <> T.pack err diff --git a/src/Text/Pandoc/Readers/Xlsx/Cells.hs b/src/Text/Pandoc/Readers/Xlsx/Cells.hs new file mode 100644 index 000000000..ca0ad24f1 --- /dev/null +++ b/src/Text/Pandoc/Readers/Xlsx/Cells.hs @@ -0,0 +1,63 @@ +{-# LANGUAGE OverloadedStrings #-} +{- | + Module : Text.Pandoc.Readers.Xlsx.Cells + Copyright : © 2025 Anton Antic + License : GNU GPL, version 2 or above + + Maintainer : Anton Antic + Stability : alpha + Portability : portable + +Cell types and parsing for XLSX. +-} +module Text.Pandoc.Readers.Xlsx.Cells + ( CellRef(..) + , XlsxCell(..) + , CellValue(..) + , parseCellRef + ) where + +import qualified Data.Text as T +import Data.Text (Text) +import Data.Char (ord, isAlpha) +import Text.Read (readMaybe) + +-- | Cell reference (A1 notation) +data CellRef = CellRef + { cellRefCol :: Int -- 1-based (A=1, B=2, ..., AA=27) + , cellRefRow :: Int -- 1-based + } deriving (Show, Eq, Ord) + +-- | Cell value types +data CellValue + = TextValue Text + | NumberValue Double + | EmptyValue + deriving (Show, Eq) + +-- | Parsed cell +data XlsxCell = XlsxCell + { cellRef :: CellRef + , cellValue :: CellValue + , cellBold :: Bool + , cellItalic :: Bool + } deriving (Show) + +-- | Parse cell reference (A1 → CellRef) +parseCellRef :: Text -> Either Text CellRef +parseCellRef ref = do + let (colStr, rowStr) = T.span isAlpha ref + + row <- case readMaybe (T.unpack rowStr) of + Just r | r > 0 -> Right r + _ -> Left $ "Invalid row: " <> rowStr + + col <- parseColumn colStr + + return $ CellRef col row + +-- | Parse column (A=1, Z=26, AA=27, etc.) +parseColumn :: Text -> Either Text Int +parseColumn colStr + | T.null colStr = Left "Empty column" + | otherwise = Right $ T.foldl' (\acc c -> acc * 26 + (ord c - ord 'A' + 1)) 0 colStr diff --git a/src/Text/Pandoc/Readers/Xlsx/Parse.hs b/src/Text/Pandoc/Readers/Xlsx/Parse.hs new file mode 100644 index 000000000..1ee350dd1 --- /dev/null +++ b/src/Text/Pandoc/Readers/Xlsx/Parse.hs @@ -0,0 +1,296 @@ +{-# LANGUAGE OverloadedStrings #-} +{- | + Module : Text.Pandoc.Readers.Xlsx.Parse + Copyright : © 2025 Anton Antic + License : GNU GPL, version 2 or above + + Maintainer : Anton Antic + 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 " $ + 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 <> ")") diff --git a/src/Text/Pandoc/Readers/Xlsx/Sheets.hs b/src/Text/Pandoc/Readers/Xlsx/Sheets.hs new file mode 100644 index 000000000..e75484790 --- /dev/null +++ b/src/Text/Pandoc/Readers/Xlsx/Sheets.hs @@ -0,0 +1,112 @@ +{-# LANGUAGE OverloadedStrings #-} +{- | + Module : Text.Pandoc.Readers.Xlsx.Sheets + Copyright : © 2025 Anton Antic + License : GNU GPL, version 2 or above + + Maintainer : Anton Antic + Stability : alpha + Portability : portable + +Conversion of XLSX sheets to Pandoc AST. +-} +module Text.Pandoc.Readers.Xlsx.Sheets + ( xlsxToOutput + ) where + +import qualified Data.Map.Strict as M +import qualified Data.Text as T +import Data.List (sort, dropWhileEnd) +import Data.Char (isSpace) +import Text.Pandoc.Definition +import Text.Pandoc.Options (ReaderOptions) +import Text.Pandoc.Readers.Xlsx.Parse +import Text.Pandoc.Readers.Xlsx.Cells +import qualified Text.Pandoc.Builder as B + +-- | Convert XLSX to Pandoc output +xlsxToOutput :: ReaderOptions -> Xlsx -> (Meta, [Block]) +xlsxToOutput _opts xlsx = + let sheets = xlsxSheets xlsx + sheetBlocks = concatMap sheetToBlocks sheets + in (mempty, sheetBlocks) + +-- | Convert sheet to blocks (header + table) +sheetToBlocks :: XlsxSheet -> [Block] +sheetToBlocks sheet = + let SheetId n = sheetId sheet + name = sheetName sheet + sheetIdent = "sheet-" <> T.pack (show n) + header = Header 2 (sheetIdent, [], []) (B.toList (B.text name)) + + -- Convert cells to table + tableBlock = case cellsToTable sheet of + Just tbl -> [tbl] + Nothing -> [] -- Empty sheet + in header : tableBlock + +-- | Convert cells to Pandoc Table +cellsToTable :: XlsxSheet -> Maybe Block +cellsToTable sheet + | M.null (sheetCells sheet) = Nothing + | otherwise = + let cells = sheetCells sheet + -- Get bounds + refs = sort $ M.keys cells + minCol = minimum $ map cellRefCol refs + maxCol = maximum $ map cellRefCol refs + minRow = minimum $ map cellRefRow refs + maxRow = maximum $ map cellRefRow refs + + -- Build dense grid + grid = [ [ M.lookup (CellRef col row) cells + | col <- [minCol..maxCol] + ] + | row <- [minRow..maxRow] + ] + + -- First row is header (simple heuristic) + (headerRow, bodyRows) = case grid of + (h:bs) -> (h, bs) + [] -> ([], []) + + -- Filter out trailing empty rows (rows with only whitespace) + filteredBodyRows = dropWhileEnd isEmptyRow bodyRows + + makeCell mcell = case mcell of + Just cell -> + let inlines = cellToInlines cell + in Cell nullAttr AlignDefault (RowSpan 1) (ColSpan 1) [Plain inlines] + Nothing -> + Cell nullAttr AlignDefault (RowSpan 1) (ColSpan 1) [Plain []] + + numCols = length headerRow + colSpec = replicate numCols (AlignDefault, ColWidthDefault) + thead = TableHead nullAttr [Row nullAttr $ map makeCell headerRow] + tbody = [TableBody nullAttr 0 [] $ map (Row nullAttr . map makeCell) filteredBodyRows] + tfoot = TableFoot nullAttr [] + + in Just $ Table nullAttr (Caption Nothing []) colSpec thead tbody tfoot + +-- | Check if a row contains only whitespace or empty cells +isEmptyRow :: [Maybe XlsxCell] -> Bool +isEmptyRow = all isEmptyCell + where + isEmptyCell Nothing = True + isEmptyCell (Just cell) = case cellValue cell of + EmptyValue -> True + TextValue t -> T.all isSpace t + NumberValue _ -> False + +-- | Convert cell to Pandoc inlines +cellToInlines :: XlsxCell -> [Inline] +cellToInlines cell = + let base = case cellValue cell of + TextValue t -> B.toList $ B.text t + NumberValue n -> [Str $ T.pack $ show n] + EmptyValue -> [] + + applyBold inls = if cellBold cell then [Strong inls] else inls + applyItalic inls = if cellItalic cell then [Emph inls] else inls + + in applyItalic $ applyBold base diff --git a/test/Tests/Helpers.hs b/test/Tests/Helpers.hs index 3e930b14a..081611ed9 100644 --- a/test/Tests/Helpers.hs +++ b/test/Tests/Helpers.hs @@ -16,6 +16,7 @@ module Tests.Helpers ( test , TestResult(..) , setupEnvironment , showDiff + , nativeDiff , testGolden , (=?>) , purely @@ -132,6 +133,19 @@ vividize (Both s _) = " " ++ s vividize (First s) = "- " ++ s vividize (Second s) = "+ " ++ s +nativeDiff :: FilePath -> Pandoc -> Pandoc -> IO (Maybe String) +nativeDiff normPath expectedNative actualNative + | expectedNative == actualNative = return Nothing + | otherwise = Just <$> do + expected <- T.unpack <$> runIOorExplode (writeNative def expectedNative) + actual <- T.unpack <$> runIOorExplode (writeNative def actualNative) + let dash = replicate 72 '-' + let diff = getDiff (lines actual) (lines expected) + return $ '\n' : dash ++ + "\n--- " ++ normPath ++ + "\n+++ " ++ "test" ++ "\n" ++ + showDiff (1,1) diff ++ dash + purely :: (b -> PandocPure a) -> b -> a purely f = either (error . show) id . runPure . f diff --git a/test/Tests/Readers/Docx.hs b/test/Tests/Readers/Docx.hs index 0bd70d0e2..76af649b4 100644 --- a/test/Tests/Readers/Docx.hs +++ b/test/Tests/Readers/Docx.hs @@ -34,20 +34,6 @@ defopts = def{ readerExtensions = getDefaultExtensions "docx" } testCompare :: String -> FilePath -> FilePath -> TestTree testCompare = testCompareWithOpts defopts - -nativeDiff :: FilePath -> Pandoc -> Pandoc -> IO (Maybe String) -nativeDiff normPath expectedNative actualNative - | expectedNative == actualNative = return Nothing - | otherwise = Just <$> do - expected <- T.unpack <$> runIOorExplode (writeNative def expectedNative) - actual <- T.unpack <$> runIOorExplode (writeNative def actualNative) - let dash = replicate 72 '-' - let diff = getDiff (lines actual) (lines expected) - return $ '\n' : dash ++ - "\n--- " ++ normPath ++ - "\n+++ " ++ "test" ++ "\n" ++ - showDiff (1,1) diff ++ dash - testCompareWithOpts :: ReaderOptions -> String -> FilePath -> FilePath -> TestTree testCompareWithOpts opts testName docxFP nativeFP = goldenTest diff --git a/test/Tests/Readers/Pptx.hs b/test/Tests/Readers/Pptx.hs index 613d5b50f..3358e4111 100644 --- a/test/Tests/Readers/Pptx.hs +++ b/test/Tests/Readers/Pptx.hs @@ -12,10 +12,8 @@ Tests for the PPTX reader. -} module Tests.Readers.Pptx (tests) where -import Data.Algorithm.Diff (getDiff) import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as B -import qualified Data.Text as T import Test.Tasty import Test.Tasty.Golden.Advanced import Tests.Helpers @@ -28,19 +26,6 @@ defopts = def{ readerExtensions = getDefaultExtensions "pptx" } testCompare :: String -> FilePath -> FilePath -> TestTree testCompare = testCompareWithOpts defopts -nativeDiff :: FilePath -> Pandoc -> Pandoc -> IO (Maybe String) -nativeDiff normPath expectedNative actualNative - | expectedNative == actualNative = return Nothing - | otherwise = Just <$> do - expected <- T.unpack <$> runIOorExplode (writeNative def expectedNative) - actual <- T.unpack <$> runIOorExplode (writeNative def actualNative) - let dash = replicate 72 '-' - let diff = getDiff (lines actual) (lines expected) - return $ '\n' : dash ++ - "\n--- " ++ normPath ++ - "\n+++ " ++ "test" ++ "\n" ++ - showDiff (1,1) diff ++ dash - testCompareWithOpts :: ReaderOptions -> String -> FilePath -> FilePath -> TestTree testCompareWithOpts opts testName pptxFP nativeFP = goldenTest diff --git a/test/Tests/Readers/Xlsx.hs b/test/Tests/Readers/Xlsx.hs new file mode 100644 index 000000000..189cd0c16 --- /dev/null +++ b/test/Tests/Readers/Xlsx.hs @@ -0,0 +1,48 @@ +{-# LANGUAGE OverloadedStrings #-} +{- | + Module : Tests.Readers.Xlsx + Copyright : © 2025 Anton Antic + License : GNU GPL, version 2 or above + + Maintainer : Anton Antic + Stability : alpha + Portability : portable + +Tests for the XLSX reader. +-} +module Tests.Readers.Xlsx (tests) where + +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as B +import Test.Tasty +import Test.Tasty.Golden.Advanced +import Tests.Helpers +import Text.Pandoc +import Text.Pandoc.UTF8 as UTF8 + +defopts :: ReaderOptions +defopts = def{ readerExtensions = getDefaultExtensions "xlsx" } + +testCompare :: String -> FilePath -> FilePath -> TestTree +testCompare = testCompareWithOpts defopts + +testCompareWithOpts :: ReaderOptions -> String -> FilePath -> FilePath -> TestTree +testCompareWithOpts opts testName xlsxFP nativeFP = + goldenTest + testName + (do nf <- UTF8.toText <$> BS.readFile nativeFP + runIOorExplode (readNative def nf)) + (do df <- B.readFile xlsxFP + runIOorExplode (readXlsx opts df)) + (nativeDiff nativeFP) + (\a -> runIOorExplode (writeNative def{ writerTemplate = Just mempty} a) + >>= BS.writeFile nativeFP . UTF8.fromText) + +tests :: [TestTree] +tests = [ testGroup "basic" + [ testCompare + "sheet extraction" + "xlsx-reader/basic.xlsx" + "xlsx-reader/basic.native" + ] + ] diff --git a/test/test-pandoc.hs b/test/test-pandoc.hs index 0d04b361f..9ae97d9c0 100644 --- a/test/test-pandoc.hs +++ b/test/test-pandoc.hs @@ -13,6 +13,7 @@ import qualified Tests.Old import qualified Tests.Readers.Creole import qualified Tests.Readers.Docx import qualified Tests.Readers.Pptx +import qualified Tests.Readers.Xlsx import qualified Tests.Readers.DokuWiki import qualified Tests.Readers.EPUB import qualified Tests.Readers.FB2 @@ -97,6 +98,7 @@ tests pandocPath = testGroup "pandoc tests" , testGroup "RTF" Tests.Readers.RTF.tests , testGroup "Docx" Tests.Readers.Docx.tests , testGroup "Pptx" Tests.Readers.Pptx.tests + , testGroup "Xlsx" Tests.Readers.Xlsx.tests , testGroup "ODT" Tests.Readers.ODT.tests , testGroup "Txt2Tags" Tests.Readers.Txt2Tags.tests , testGroup "EPUB" Tests.Readers.EPUB.tests @@ -126,4 +128,4 @@ main = do _ -> inDirectory "test" $ do fp <- getExecutablePath -- putStrLn $ "Using pandoc executable at " ++ fp - defaultMain $ tests fp \ No newline at end of file + defaultMain $ tests fp diff --git a/test/xlsx-reader/basic.native b/test/xlsx-reader/basic.native new file mode 100644 index 000000000..f69f78a41 --- /dev/null +++ b/test/xlsx-reader/basic.native @@ -0,0 +1,381 @@ +Pandoc + Meta { unMeta = fromList [] } + [ Header 2 ( "sheet-1" , [] , [] ) [ Str "Main" ] + , Table + ( "" , [] , [] ) + (Caption Nothing []) + [ ( AlignDefault , ColWidthDefault ) + , ( AlignDefault , ColWidthDefault ) + , ( AlignDefault , ColWidthDefault ) + ] + (TableHead + ( "" , [] , [] ) + [ Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Strong [ Str "Person" ] ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Strong [ Str "Age" ] ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Strong [ Str "Location" ] ] ] + ] + ]) + [ TableBody + ( "" , [] , [] ) + (RowHeadColumns 0) + [] + [ Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Anton" , Space , Str "Antich" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "23.0" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Switzerland" ] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "James" , Space , Str "Bond" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "35.0" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Moscow" ] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain + [ Str "Just" + , Space + , Str "a" + , Space + , Str "random" + , Space + , Str "cell" + ] + ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + ] + ] + ] + (TableFoot ( "" , [] , [] ) []) + , Header 2 ( "sheet-2" , [] , [] ) [ Str "Secondary" ] + , Table + ( "" , [] , [] ) + (Caption Nothing []) + [ ( AlignDefault , ColWidthDefault ) + , ( AlignDefault , ColWidthDefault ) + , ( AlignDefault , ColWidthDefault ) + , ( AlignDefault , ColWidthDefault ) + , ( AlignDefault , ColWidthDefault ) + ] + (TableHead + ( "" , [] , [] ) + [ Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain + [ Str "Sum" + , Space + , Str "of" + , Space + , Str "Age" + ] + ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Column" , Space , Str "Labels" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + ] + ]) + [ TableBody + ( "" , [] , [] ) + (RowHeadColumns 0) + [] + [ Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Row" , Space , Str "Labels" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Moscow" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Switzerland" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "(blank)" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Grand" , Space , Str "Total" ] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Anton" , Space , Str "Antich" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "23.0" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "23.0" ] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "James" , Space , Str "Bond" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "35.0" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "35.0" ] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "(blank)" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + ] + , Row + ( "" , [] , [] ) + [ Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "Grand" , Space , Str "Total" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "35.0" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "23.0" ] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [] ] + , Cell + ( "" , [] , [] ) + AlignDefault + (RowSpan 1) + (ColSpan 1) + [ Plain [ Str "58.0" ] ] + ] + ] + ] + (TableFoot ( "" , [] , [] ) []) + ] diff --git a/test/xlsx-reader/basic.xlsx b/test/xlsx-reader/basic.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..55d62d56ecb4e9c41d4f601be5e0ba20228f2355 GIT binary patch literal 13604 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 literal 0 HcmV?d00001