pandoc-3.14: src/Text/Pandoc/Readers/Xlsx/Sheets.hs
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : Text.Pandoc.Readers.Xlsx.Sheets
Copyright : © 2025 Anton Antic
License : GNU GPL, version 2 or above
Maintainer : Anton Antic <anton@everworker.ai>
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.Set as S
import qualified Data.Text as T
import Data.List (sort, dropWhileEnd)
import Numeric (floatToDigits)
import Data.Char (isSpace)
import Text.Pandoc.Definition
import Text.Pandoc.Options (ReaderOptions)
import Text.Pandoc.Readers.OOXML.Charts (chartToBlocks)
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 ++ concatMap chartToBlocks (sheetCharts sheet)
-- | 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 = [ [ (CellRef col row, 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 . map snd) bodyRows
lastRow = minRow + length filteredBodyRows
-- A merged range becomes one cell spanning its rows and
-- columns, clipped to the table and kept within the header row
-- or within the body, as a cell can't span both.
spans = M.fromList
[ (from, (rows, cols))
| (from, to) <- sheetMerges sheet
, cellRefCol from >= minCol, cellRefCol from <= maxCol
, cellRefRow from >= minRow, cellRefRow from <= lastRow
, let rows = if cellRefRow from == minRow
then 1
else min (cellRefRow to) lastRow
- cellRefRow from + 1
cols = min (cellRefCol to) maxCol - cellRefCol from + 1
, rows > 1 || cols > 1
]
covered = S.fromList
[ CellRef col row
| (CellRef c r, (rows, cols)) <- M.toList spans
, row <- [r .. r + rows - 1], col <- [c .. c + cols - 1]
, (col, row) /= (c, r)
]
makeCell (ref, mcell) =
let (rows, cols) = M.findWithDefault (1, 1) ref spans
in Cell nullAttr AlignDefault (RowSpan rows) (ColSpan cols)
[Plain (maybe [] cellToInlines mcell)]
makeRow = Row nullAttr . map makeCell .
filter (\(ref, _) -> not (ref `S.member` covered))
numCols = length headerRow
colSpec = replicate numCols (AlignDefault, ColWidthDefault)
thead = TableHead nullAttr [makeRow headerRow]
tbody = [TableBody nullAttr 0 [] $ map makeRow 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
BoolValue _ -> 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 $ formatGeneral n]
BoolValue b -> [Str $ if b then "TRUE" else "FALSE"]
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
-- | Render a number as Excel's General format does, without rounding
-- it to fit a column: at most 15 significant digits (Excel's precision)
-- and no trailing zeros. Numbers with 12 or more digits before the
-- decimal point use scientific notation (@1.23456789012E+11@), as General
-- does, keeping all their digits; so do numbers below 1e-9.
formatGeneral :: Double -> T.Text
formatGeneral x
| isNaN x || isInfinite x = T.pack (show x)
| x == 0 = "0"
| otherwise = T.pack $ sign ++ body
where
sign = if x < 0 then "-" else ""
(ds, e) = roundDigits 15 (floatToDigits 10 (abs x))
digits = concatMap show ds
-- abs x == 0.d1d2d3... * 10^e
body
| e > 11 || e < -8 = scientific
| e <= 0 = "0." ++ replicate (negate e) '0' ++ digits
| length ds <= e = digits ++ replicate (e - length ds) '0'
| otherwise = take e digits ++ "." ++ drop e digits
scientific =
let mantissa = case digits of
d:rest@(_:_) -> d : '.' : rest
_ -> digits
ex = e - 1
in mantissa ++ "E" ++ (if ex < 0 then "-" else "+") ++
(let a = show (abs ex) in if length a < 2 then '0' : a else a)
-- | Round a digit list (as from 'floatToDigits') to at most n digits,
-- dropping trailing zeros.
roundDigits :: Int -> ([Int], Int) -> ([Int], Int)
roundDigits n (ds, e) =
case splitAt n ds of
(_, []) -> (ds, e)
(keep, d:_) ->
case if d >= 5 then carry (reverse keep) else Just keep of
Just ds' -> (dropWhileEnd (== 0) ds', e)
Nothing -> ([1], e + 1)
where
-- Add one to a reversed digit list; Nothing if it overflows (all 9s).
carry [] = Nothing
carry (9:rest) = (++ [0]) <$> carry rest
carry (k:rest) = Just (reverse (k + 1 : rest))