~jaro/balkon

ref: f72b5805e6f857d963bf025d464a86a80b9374b1 balkon/src/Data/Text/ParagraphLayout/Internal/LinePagination.hs -rw-r--r-- 7.4 KiB
f72b5805Jaro Add stress test for Heisenbug hunting. 1 year, 5 months 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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
-- | 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
    , 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 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 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 :: 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 satisfying the given constraints.
--
-- This is a low-level function that makes no compromises.
bestSplit :: Line 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
    :: 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