~jaro/balkon

ref: d08655932562889f01aae90ed6e201fcddbaf169 balkon/src/Data/Text/ParagraphLayout/Internal/LinePagination.hs -rw-r--r-- 7.0 KiB
d0865593Jaro Implement generic pagination internally. 1 year, 1 month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
-- | 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
    (Line
    ,PageContinuity(Break, Continue)
    ,bestSplit
    ,lineHeight
    ,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 Line a where
    lineHeight :: a -> Int32

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

-- | Represents the best place, as determined by the `paginateLines` function,
-- to place the first line of paginated text.
data PageContinuity

    = Continue
    -- ^ The lines are split so that the prefix 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 lines are split so that the prefix 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 lines at all.

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

-- | Split a list of lines in order to fit the given pagination constraints.
--
-- 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 :: Line 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 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.
bestSplit :: Line a => Word -> Word -> Int32 -> [a] -> ([a], [a])
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
    :: Line 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 :: Line 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 :: Line a => [a] -> NonEmpty (Int32, ([a], [a]))
splitsWithTotal ls = zeroSplit :| splits
    where
        zeroSplit = (zeroTotal, (zeroClosed, ls))
        splits = splitsWithTotal' zeroTotal zeroClosed ls
        zeroClosed = []
        zeroTotal = 0

splitsWithTotal' :: Line 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