packages feed

balkon-1.3.0.0: src/Data/Text/ParagraphLayout/Internal/LinePagination.hs

-- | Breaking generic lines into discrete pages.
--
-- This can be used for implementing fragmentation breaks as defined by the
-- [CSS Fragmentation Module Level 3](https://www.w3.org/TR/css-break-3/).
-- All types of fragmentation containers (pages, columns, regions) are referred
-- to as pages within this module.
--
-- Assumptions:
--
-- - Lines are laid out from top to bottom.
-- - Each page has the same size.
--   (Preceding context may limit the space available on the given page, but it
--   is assumed that the space on every following page can be used in full.)
module Data.Text.ParagraphLayout.Internal.LinePagination
    ( LineHeight
    , lineHeight
    , PageContinuity (Break, Continue)
    , bestSplit
    , paginateLines
    )
where

import Data.Int (Int32)
import Data.List (dropWhileEnd, genericLength)
import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Data.List.NonEmpty as NonEmpty

-- | Representation of a line of text with a known height.
--
-- Lines are assumed to be tightly packed without overlaps,
-- so that the vertical space taken up by multiple lines
-- is equal to the sum of the height of each line.
class LineHeight a where
    lineHeight :: a -> Int32

-- | A trivial instance of `LineHeight` that is just a height.
--
-- For testing purposes.
instance LineHeight Int32 where
    lineHeight = id

-- | Represents the best place to place a chunk of paginated content.
data PageContinuity

    = Continue
    -- ^ The content is split so that a given chunk can continue
    -- on the same page as its preceding context.
    --
    -- This may be because all constraints were met, or because
    -- adding a page break would have no benefit.

    | Break
    -- ^ The content is split so that a given chunk should begin
    -- on a new page.
    --
    -- This may be because the current page does not have enough
    -- space to preserve orphan/widow constrains, or because it
    -- does not have space for any content at all.

    deriving (Eq, Show, Read, Enum, Bounded)

-- | Split a list of lines in order to meet the given pagination constraints.
--
-- This is a high-level function that produces the best usable result,
-- even if some constraints have to be violated.
--
-- The first component of the output determines whether a page break should
-- be inserted before any of the given lines.
--
-- The second component of the output contains the lines that fit on one page
-- and satisfy the given constraints as much as possible.
--
-- The third component of the output contains all remaining lines. If non-empty,
-- these can be passed to this function again to produce more pages.
paginateLines :: LineHeight a
    => Word
    -- ^ Minimum number of lines to keep before a page break ("orphans"),
    -- if possible.
    -> Word
    -- ^ Minimum number of lines to keep after a page break ("widows"),
    -- if possible.
    -> Int32
    -- ^ Height available when continuing the current page.
    -- Used for calculating results with `PageContinuity` set to `Continue`.
    -> Int32
    -- ^ Height available when breaking onto a new page.
    -- Used for calculating results with `PageContinuity` set to `Break`.
    -> [a]
    -- ^ Lines to paginate.
    -> (PageContinuity, [a], [a])
    -- ^ The best page break found.
paginateLines o w h1 h2 ls
    -- First, attempt to satisfy the orphans and widows constraints, following
    -- "Rule 3" of <https://www.w3.org/TR/css-break-3/#unforced-breaks>.
    | canBeFinal constrained
        = accept constrained Continue
    | canBeFinal constrainedNextPage
        = accept constrainedNextPage Break
    -- Next, drop "rule 3" to provide more break points.
    | canBeFinal relaxed
        = accept relaxed Continue
    | canBeFinal relaxedNextPage
        = accept relaxedNextPage Break
    -- If overflow is unavoidable, break after the first line.
    -- Try adding a page break if it would make the line fit better.
    | h1 >= h2
        = accept overflowing Continue
    | otherwise
        = accept overflowing Break
    where
        accept (prefix, suffix) continuity = (continuity, prefix, suffix)
        constrained = bestSplit o w h1 ls
        constrainedNextPage = bestSplit o w h2 ls
        relaxed = bestSplit 1 1 h1 ls
        relaxedNextPage = bestSplit 1 1 h2 ls
        overflowing = split1 ls

-- | Determine whether pagination will converge if pages are split
-- in the given way.
canBeFinal :: ([a], [a]) -> Bool
-- An empty suffix is OK, because this means the end of pagination.
canBeFinal (_, []) = True
-- An empty prefix with a non-empty suffix is unacceptable, because
-- repeating pagination on the suffix would diverge.
canBeFinal ([], _) = False
-- A non-empty prefix with a non-empty suffix is OK, because
-- repeating pagination on the suffix will eventually converge.
canBeFinal (_, _) = True

-- | Split a non-empty list after its first element,
-- or split an empty list into two empty lists.
split1 :: [a] -> ([a], [a])
split1 xs = (take 1 xs, drop 1 xs)

-- | Split a list of lines so that the prefix contains as many lines
-- as possible while satisfying the given constraints.
--
-- This is a low-level function that makes no compromises.
bestSplit :: LineHeight a
    => Word
    -- ^ Number of lines at the beginning ("orphans") to keep together.
    -> Word
    -- ^ Number of lines at the end ("widows") to keep together.
    -> Int32
    -- ^ Maximum total height of lines in the prefix.
    -> [a]
    -- ^ Lines to split.
    -> ([a], [a])
    -- ^ Two lists of lines that yield the original list when concatenated,
    -- where the prefix, if non-empty, matches the given orphan, widow, and
    -- maximum height constraints.
bestSplit o w h ls = NonEmpty.last $ constrainedSplits o w h ls

-- | Split a list of lines in every possible way, from shortest prefix
-- to longest, as long as the total of line heights in the prefix does
-- not exceed @h@, the first @o@ lines ("orphans") are kept together,
-- and the last @w@ lines ("widows") are kept together.
constrainedSplits
    :: LineHeight a => Word -> Word -> Int32 -> [a] -> NonEmpty ([a], [a])
constrainedSplits o w h ls = zeroSplit :| dropWhileEnd violating splits
    where
        zeroSplit :| splits = fittingSplits h ls
        violating = not . meetingConstraints
        meetingConstraints (prefix, suffix)
            = (null prefix || genericLength prefix >= o)
            && (null suffix || genericLength suffix >= w)

-- | Split a list of lines in every possible way, from shortest prefix
-- to longest, as long as the total of line heights in the prefix does
-- not exceed @h@.
fittingSplits :: LineHeight a => Int32 -> [a] -> NonEmpty ([a], [a])
fittingSplits h ls = fmap snd $ zeroSplit :| takeWhile fitting splits
    where
        zeroSplit :| splits = splitsWithTotal ls
        fitting (height, _) = height <= h

-- | Split a list of lines in every possible way, from shortest prefix
-- to longest, and keep a running total of line heights in the prefix.
splitsWithTotal :: LineHeight a => [a] -> NonEmpty (Int32, ([a], [a]))
splitsWithTotal ls = zeroSplit :| splits
    where
        zeroSplit = (zeroTotal, (zeroClosed, ls))
        splits = splitsWithTotal' zeroTotal zeroClosed ls
        zeroClosed = []
        zeroTotal = 0

splitsWithTotal' :: LineHeight a => Int32 -> [a] -> [a] -> [(Int32, ([a], [a]))]
splitsWithTotal' _ _ [] = []
splitsWithTotal' total closed (x : xs) = split : splits
    where
        split = (newTotal, (reverse newClosed, xs))
        splits = splitsWithTotal' newTotal newClosed xs
        newClosed = x : closed
        newTotal = total + lineHeight x