packages feed

pandoc-3.2: src/Text/Pandoc/Writers/Docx/Table.hs

{-# LANGUAGE LambdaCase          #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
{- |
Module      : Text.Pandoc.Writers.Docx.Table
Copyright   : Copyright (C) 2012-2024 John MacFarlane
License     : GNU GPL, version 2 or above
Maintainer  : John MacFarlane <jgm@berkeley.edu>

Conversion of table blocks to docx.
-}
module Text.Pandoc.Writers.Docx.Table
  ( tableToOpenXML
  , rowToOpenXML
  , OOXMLRow (..)
  , OOXMLCell (..)
  , RowType (..)
  ) where

import Control.Monad.State.Strict ( modify, gets )
import Control.Monad ( unless , zipWithM )
import Control.Monad.Except ( throwError )
import Data.Array ( elems, (!), assocs, indices )
import Data.Text (Text)
import Data.Maybe (catMaybes)
import Text.Pandoc.Definition
    ( ColSpec,
      Caption(Caption),
      Format(Format),
      Attr,
      Block(Para, Plain),
      Inline(Str, Span, RawInline),
      Alignment(..),
      RowSpan(..),
      ColSpan(..),
      ColWidth(..) )
import Text.Pandoc.Class.PandocMonad (PandocMonad)
import Text.Pandoc.Translations (translateTerm)
import Text.Pandoc.Writers.Docx.Types
    ( WS,
      WriterState(stNextTableNum, stInTable),
      WriterEnv(..),
      setFirstPara,
      pStyleM,
      withParaProp,
      withParaPropM )
import Control.Monad.Reader (asks)
import Text.Pandoc.Shared ( tshow, stringify )
import Text.Pandoc.Options (WriterOptions, isEnabled)
import Text.Pandoc.Extensions (Extension(Ext_native_numbering))
import Text.Pandoc.Error (PandocError(PandocSomeError))
import Text.Printf (printf)
import Text.Pandoc.Writers.GridTable
    ( rowArray,
      ColIndex,
      GridCell(..),
      Part(Part, partCellArray, partRowAttrs),
      RowIndex )
import Text.Pandoc.Writers.OOXML ( mknode )
import Text.Pandoc.XML.Light.Proc ( onlyElems )
import Text.Pandoc.XML.Light.Types
    ( Content(Elem), Element(elName), QName(qName) )
import qualified Data.Text as T
import qualified Text.Pandoc.Translations as Term
import qualified Text.Pandoc.Writers.GridTable as Grid

tableToOpenXML :: PandocMonad m
               => WriterOptions
               -> ([Block] -> WS m [Content])
               -> Grid.Table
               -> WS m [Content]
tableToOpenXML opts blocksToOpenXML gridTable = do
  setFirstPara
  let (Grid.Table (ident,_,_) caption colspecs _rowheads thead tbodies tfoot) =
        gridTable
  let (Caption _maybeShortCaption captionBlocks) = caption
  tablenum <- gets stNextTableNum
  unless (null captionBlocks) $
    modify $ \st -> st{ stNextTableNum = tablenum + 1 }
  let tableid = if T.null ident
                   then "table" <> tshow tablenum
                   else ident
  tablename <- translateTerm Term.Table
  let captionStr = stringify captionBlocks
  let aligns = map fst $ elems colspecs
  captionXml <- if null captionBlocks
                then return []
                else withParaPropM (pStyleM "Table Caption")
                     $ blocksToOpenXML
                     $ if isEnabled Ext_native_numbering opts
                          then addLabel tableid tablename tablenum captionBlocks
                          else captionBlocks
  -- We set "in table" after processing the caption, because we don't
  -- want the "Table Caption" style to be overwritten with "Compact".
  modify $ \s -> s { stInTable = True }
  head' <- cellGridToOpenXML blocksToOpenXML HeadRow aligns thead
  bodies <- mapM (cellGridToOpenXML blocksToOpenXML BodyRow aligns) tbodies
  foot' <- cellGridToOpenXML blocksToOpenXML FootRow aligns tfoot

  let hasHeader = not . null . indices . partRowAttrs $ thead
  let hasFooter = not . null . indices . partRowAttrs $ tfoot
  -- for compatibility with Word <= 2007, we include a val with a bitmask
  -- 0×0020  Apply first row conditional formatting
  -- 0×0040  Apply last row conditional formatting
  -- 0×0080  Apply first column conditional formatting
  -- 0×0100  Apply last column conditional formatting
  -- 0×0200  Do not apply row banding conditional formatting
  -- 0×0400  Do not apply column banding conditional formattin
  let tblLookVal = if hasHeader then (0x20 :: Int) else 0
  let (gridCols, tblWattr) = tableLayout (elems colspecs)
  listLevel <- asks envListLevel
  let indent = (listLevel + 1) * 720
  let hasWidths = not $ all ((== ColWidthDefault) . snd) colspecs
  let tbl = mknode "w:tbl" []
        ( mknode "w:tblPr" []
          ( [ mknode "w:tblStyle" [("w:val","Table")] (),
              mknode "w:tblW" tblWattr () ] ++
            [ mknode "w:jc" [("w:val","left")] () | indent > 0 ] ++
            [ mknode "w:tblInd" [("w:w", tshow indent),("w:type","dxa")] ()
                | indent > 0 ] ++
            [ mknode "w:tblLayout" [("w:type", "fixed")] () | hasWidths ] ++
            [ mknode "w:tblLook" [("w:firstRow",if hasHeader then "1" else "0")
                                 ,("w:lastRow",if hasFooter then "1" else "0")
                                 ,("w:firstColumn","0")
                                 ,("w:lastColumn","0")
                                 ,("w:noHBand","0")
                                 ,("w:noVBand","0")
                                 ,("w:val", T.pack $ printf "%04x" tblLookVal)
                                 ] () ] ++
            [ mknode "w:tblCaption" [("w:val", captionStr)] ()
            | not (T.null captionStr) ]
          )
          : mknode "w:tblGrid" [] gridCols
          : head' ++ mconcat bodies ++ foot'
        )
  modify $ \s -> s { stInTable = False }
  return $ captionXml ++ [Elem tbl]

addLabel :: Text -> Text -> Int -> [Block] -> [Block]
addLabel tableid tablename tablenum bs =
  case bs of
    (Para ils : rest)  -> Para (label : Str ": " : ils) : rest
    (Plain ils : rest) -> Plain (label : Str ": " : ils) : rest
    _ -> Para [label] : bs
 where
  label = Span (tableid,[],[])
            [Str (tablename <> "\160"),
             RawInline (Format "openxml")
               ("<w:fldSimple w:instr=\"SEQ Table"
               <> " \\* ARABIC \"><w:r><w:t>"
               <> tshow tablenum
               <> "</w:t></w:r></w:fldSimple>")]

-- | Parts of a table
data RowType = HeadRow | BodyRow | FootRow

alignmentToString :: Alignment -> Text
alignmentToString = \case
  AlignLeft    -> "left"
  AlignRight   -> "right"
  AlignCenter  -> "center"
  AlignDefault -> "left"

tableLayout :: [ColSpec] -> ([Element], [(Text, Text)])
tableLayout specs =
  let
    textwidth = 7920  -- 5.5 in in twips       (1 twip == 1/20 pt)
    fullrow   = 5000  -- 100% specified in pct (1 pct  == 1/50th of a percent)
    ncols = length specs
    getWidth = \case
      ColWidth n -> n
      _          -> 0
    widths = map (getWidth . snd) specs
    rowwidth  = round (fullrow * sum widths) :: Int
    widthToTwips w = floor (textwidth * w)   :: Int
    mkGridCol w = mknode "w:gridCol" [("w:w", tshow (widthToTwips w))] ()
  in if all (== 0) widths
     then ( replicate ncols $ mkGridCol (1.0 / fromIntegral ncols)
          , [ ("w:type", "auto"), ("w:w", "0")])
     else ( map mkGridCol widths
          , [ ("w:type", "pct"), ("w:w", tshow rowwidth) ])

cellGridToOpenXML :: PandocMonad m
                  => ([Block] -> WS m [Content])
                  -> RowType
                  -> [Alignment]
                  -> Part
                  -> WS m [Element]
cellGridToOpenXML blocksToOpenXML rowType aligns part@(Part _ cellArray _) =
  if null (elems cellArray)
  then return mempty
  else partToRows rowType aligns part >>=
       fmap catMaybes . mapM (rowToOpenXML blocksToOpenXML)

data OOXMLCell
  = OOXMLCell Attr Alignment RowSpan ColSpan [Block]
  | OOXMLCellMerge ColSpan

data OOXMLRow = OOXMLRow RowType Attr [OOXMLCell]

partToRows :: PandocMonad m
           => RowType -> [Alignment] -> Part -> WS m [OOXMLRow]
partToRows rowType aligns part = do
  let toOOXMLCell :: PandocMonad m =>
        Alignment -> RowIndex -> ColIndex -> GridCell -> WS m [OOXMLCell]
      toOOXMLCell columnAlign ridx cidx = \case
        UnassignedCell ->
          throwError $ PandocSomeError "Encountered unassigned table cell"
        ContentCell attr align rowspan colspan blocks -> do
          -- Respect non-default, cell specific alignment.
          let align' = case align of
                AlignDefault -> columnAlign
                _            -> align
          return [OOXMLCell attr align' rowspan colspan blocks]
        ContinuationCell idx'@(ridx',cidx') | ridx /= ridx', cidx == cidx' -> do
          case (partCellArray part)!idx' of
            (ContentCell _ _ _ colspan _) -> return [OOXMLCellMerge colspan]
            x -> error $ "Content cell expected, got, " ++ show x ++
                         " at index " ++ show idx'
        _ -> return mempty
  let mkRow :: PandocMonad m => (RowIndex, Attr) -> WS m OOXMLRow
      mkRow (ridx, attr) = do
        cs <- zipWithM (\align -> uncurry $ toOOXMLCell align ridx)
                        aligns
                        (assocs . rowArray ridx $ partCellArray part)
        return $ OOXMLRow rowType attr . mconcat $ cs
  mapM mkRow $ assocs (partRowAttrs part)

rowToOpenXML :: PandocMonad m
             => ([Block] -> WS m [Content])
             -> OOXMLRow
             -> WS m (Maybe Element)
rowToOpenXML blocksToOpenXML (OOXMLRow rowType _attr cells)
  | null cells = return Nothing
  | otherwise = do
    xmlcells <- mapM (ooxmlCellToOpenXML blocksToOpenXML) cells
    let addTrPr = case rowType of
          HeadRow -> (mknode "w:trPr" []
                      [mknode "w:tblHeader" [("w:val", "on")] ()] :)
          BodyRow -> id
          FootRow -> id
    return $ Just $ mknode "w:tr" [] (addTrPr xmlcells)

ooxmlCellToOpenXML :: PandocMonad m
                   => ([Block] -> WS m [Content])
                   -> OOXMLCell
                   -> WS m Element
ooxmlCellToOpenXML blocksToOpenXML = \case
  OOXMLCellMerge (ColSpan colspan) -> do
    return $ mknode "w:tc" []
      [ mknode "w:tcPr" [] [ mknode "w:gridSpan" [("w:val", tshow colspan)] ()
                           , mknode "w:vMerge"   [("w:val", "continue")] () ]
      , mknode "w:p" [] [mknode "w:pPr" [] ()]]
  OOXMLCell _attr align rowspan (ColSpan colspan) contents -> do
    compactStyle <- pStyleM "Compact"
    es <- withParaProp (alignmentFor align) $ blocksToOpenXML contents
    -- Table cells require a <w:p> element, even an empty one!
    -- Not in the spec but in Word 2007, 2010. See #4953. And
    -- apparently the last element must be a <w:p>, see #6983.
    return . mknode "w:tc" [] $
      Elem
       (mknode "w:tcPr" [] ([ mknode "w:gridSpan" [("w:val", tshow colspan)] ()
                            | colspan > 1] ++
                            [ mknode "w:vMerge" [("w:val", "restart")] ()
                            | rowspan > RowSpan 1 ])) :
      if null contents
      then [Elem $ mknode "w:p" [] [mknode "w:pPr" [] [compactStyle]]]
      else case reverse (onlyElems es) of
             b:e:_ | qName (elName b) == "bookmarkEnd"  -- y tho?
                   , qName (elName e) == "p" -> es
             e:_   | qName (elName e) == "p" -> es
             _ -> es ++ [Elem $ mknode "w:p" [] ()]

alignmentFor :: Alignment -> Element
alignmentFor al = mknode "w:jc" [("w:val",alignmentToString al)] ()