kb-text-layout-0.1.0.1: src/KB/Text/Layout/Break.hs
module KB.Text.Layout.Break
( -- * Breaking prepared text into lines
layoutGreedy
, layoutOptimal
, LineRange (..)
, Cursor (..)
, LineEnd (..)
-- * Breaking one line at a time
, layoutNextLineRange
-- * Extracting line contents
, materializeSlices
, LineSlice (..)
, materializeLineRange
-- * Inspecting layouts
, layoutStats
, LayoutStats (..)
, lineStretch
, LineStretch (..)
, shrinkwrap
) where
import Data.Foldable (foldl')
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import KB.Text.Layout.Analysis (BreakKind (..))
import KB.Text.Layout.Measure (MeasuredSegment (..), PreparedText (..))
layoutGreedy :: PreparedText -> Float -> [LineRange]
layoutGreedy prepared maxWidth = go (Cursor 0 0)
where
go cursor =
case layoutNextLineRange prepared maxWidth cursor of
Nothing -> []
Just (line, Nothing) -> [line]
Just (line, Just next) -> line : go next
layoutOptimal :: PreparedText -> Float -> [LineRange]
layoutOptimal prepared target =
case chosen of
Just lns -> lns
Nothing -> layoutGreedy prepared target
where
segs = prepared.segments
total = Vector.length segs
flowWidth seg = case seg.kind of
SoftHyphen -> 0
_ -> seg.width
spaceWidth seg = case seg.kind of
Space -> seg.width
_ -> 0
prefixFlow = Vector.scanl' (\acc seg -> acc + flowWidth seg) 0 segs
prefixSpace = Vector.scanl' (\acc seg -> acc + spaceWidth seg) 0 segs
candidates :: Vector Breakpoint
candidates =
Vector.fromList $
concatMap breakable [0 .. total - 1] <> [Breakpoint{to = total, next = total, hyphen = False, forced = True}]
where
breakable i = case (segs Vector.! i).kind of
Space -> [plain i]
Tab -> [plain i]
PreservedSpace -> [plain i]
ZeroWidthBreak -> [plain i]
SoftHyphen -> [Breakpoint{to = i, next = i + 1, hyphen = True, forced = False}]
HardBreak -> [Breakpoint{to = i, next = i + 1, hyphen = False, forced = True}]
Atomic -> [Breakpoint{to = i, next = i, hyphen = False, forced = False}, Breakpoint{to = i + 1, next = i + 1, hyphen = False, forced = False}]
Word -> []
Glue -> []
plain i = Breakpoint{to = i, next = i + 1, hyphen = False, forced = False}
skipSpaces i
| i < total, (segs Vector.! i).kind == Space = skipSpaces (i + 1)
| otherwise = i
lineStart i = skipSpaces i
lineOf from cand =
let
a = lineStart from
hyphenWidth = if cand.hyphen then (segs Vector.! cand.to).width else 0
natural = prefixFlow Vector.! cand.to - prefixFlow Vector.! a + hyphenWidth
stretchable = prefixSpace Vector.! cand.to - prefixSpace Vector.! a
in
(a, natural, stretchable)
badness natural stretchable free
| slack >= 0 =
if free then
Just 0
else
if stretchable <= 0 then
Just $! if slack < 1e-4 then 0 else looseNoGlue + slack * 1000
else
Just $! 100 * ratio * ratio * ratio
| negate slack > shrink + 1e-4 = Nothing
| otherwise = Just $! 100 * tightness * tightness * tightness
where
looseNoGlue :: Float
looseNoGlue = 1e7
slack = target - natural
ratio = slack / (0.5 * stretchable)
shrink = stretchable / 3
tightness = negate slack / max shrink 1e-6
demerits b hyphen = (10 + b) * (10 + b) + (if hyphen then 2500 else 0)
lastForcedBefore :: Vector Int
lastForcedBefore = Vector.fromList $ scanl step (-1) [0 .. Vector.length candidates - 2]
where
step acc j = if (candidates Vector.! j).forced then j else acc
best :: Vector (Maybe (Float, Int))
best = Vector.generate (Vector.length candidates) bestAt
where
bestAt j =
let
cand = candidates Vector.! j
floor_ = lastForcedBefore Vector.! j
fromStart
| floor_ == -1 = attempt (-1) 0
| otherwise = Nothing
fromPrev i
| i < max floor_ 0 = []
| otherwise = case best Vector.! i of
Nothing -> rest
Just (cost, _) -> maybe rest (: rest) do
d <- lineCost (candidates Vector.! i).next
pure (cost + d, i)
where
rest = fromPrev (i - 1)
attempt i from = (\d -> (d, i)) <$> lineCost from
lineCost from =
let (_a, natural, stretchable) = lineOf from cand
in demerits <$> badness natural stretchable cand.forced <*> pure cand.hyphen
options = maybe [] (: []) fromStart <> fromPrev (j - 1)
in
case options of
[] -> Nothing
_ -> Just $! minimum options
chosen = do
let final = Vector.length candidates - 1
_ <- best Vector.! final
let walk j acc
| j == -1 = acc
| otherwise = case best Vector.! j of
Nothing -> acc
Just (_, i) ->
let
cand = candidates Vector.! j
from = if i == -1 then 0 else (candidates Vector.! i).next
(a, natural, _) = lineOf from cand
ended
| cand.hyphen = Hyphenated
| cand.to >= total = Finished
| cand.forced = HardBroken
| otherwise = Wrapped
line =
LineRange
{ from = Cursor a 0
, to = Cursor cand.to 0
, width = natural
, ended
}
in
walk i $ if a >= total then acc else (line : acc)
pure $! walk final []
data LineRange = LineRange
{ from :: Cursor
, to :: Cursor
, width :: Float
, ended :: LineEnd
}
deriving stock (Eq, Show)
data Cursor = Cursor
{ segment :: Int
, grapheme :: Int
}
deriving stock (Eq, Ord, Show)
data LineEnd
= Wrapped
| Hyphenated
| Overflowed
| HardBroken
| Finished
deriving stock (Eq, Ord, Show)
data Breakpoint = Breakpoint
{ to :: Int
, next :: Int
, hyphen :: Bool
, forced :: Bool
}
layoutNextLineRange :: PreparedText -> Float -> Cursor -> Maybe (LineRange, Maybe Cursor)
layoutNextLineRange prepared maxWidth cursor
| start.segment >= total = Nothing
| otherwise = Just (scanSeg start.segment start.grapheme 0 0 Nothing)
where
segs = prepared.segments
total = Vector.length segs
start = skipSpaces cursor
skipSpaces c
| c.segment < total
, c.grapheme == 0
, (segs Vector.! c.segment).kind == Space =
skipSpaces Cursor{segment = c.segment + 1, grapheme = 0}
| otherwise = c
emit to width ended = LineRange{from = start, to, width, ended}
scanSeg i g acc visible cand
| i >= total = (emit (Cursor total 0) visible Finished, Nothing)
| otherwise =
let
seg = segs Vector.! i
segWidth = if g == 0 then seg.width else sum (map snd (dropChars g seg.graphemeWidths))
after = Cursor (i + 1) 0
spaceLike =
scanSeg
(i + 1)
0
(acc + segWidth)
visible
(Just Candidate{to = Cursor i 0, width = visible, end = Wrapped, next = after})
fitOrBreak
| acc + segWidth <= maxWidth + epsilon =
scanSeg (i + 1) 0 (acc + segWidth) (acc + segWidth) cand
| otherwise =
case cand of
Just c -> (emit c.to c.width c.end, Just c.next)
Nothing -> emergency seg i g acc
in
case seg.kind of
HardBreak -> (emit (Cursor i 0) visible HardBroken, Just after)
Space -> spaceLike
PreservedSpace -> spaceLike
Tab -> spaceLike
ZeroWidthBreak -> spaceLike
SoftHyphen
| visible + segWidth <= maxWidth + epsilon ->
scanSeg (i + 1) 0 acc visible $
Just
Candidate
{ to = Cursor i 0
, width = visible + segWidth
, end = Hyphenated
, next = after
}
| otherwise -> scanSeg (i + 1) 0 acc visible cand
Atomic ->
let candBefore
| acc > 0
, maybe True (\c -> c.width < visible) cand =
Just Candidate{to = Cursor i 0, width = visible, end = Wrapped, next = Cursor i 0}
| otherwise = cand
in if acc + segWidth <= maxWidth + epsilon then
scanSeg (i + 1) 0 (acc + segWidth) (acc + segWidth) $
Just Candidate{to = after, width = acc + segWidth, end = Wrapped, next = after}
else case candBefore of
Just c -> (emit c.to c.width c.end, Just c.next)
Nothing -> (emit after (acc + segWidth) Overflowed, Just after)
Word -> fitOrBreak
Glue -> fitOrBreak
emergency seg i g acc =
let
ws = dropChars g seg.graphemeWidths
counted = countFits acc ws
count
| counted == 0 && acc <= epsilon = 1
| otherwise = counted
taken = take count ws
g' = g + sum (map fst taken)
takenWidth = sum (map snd taken)
in
if count == 0 then
(emit (Cursor i g) acc Overflowed, Just (Cursor i g))
else
(emit (Cursor i g') (acc + takenWidth) Overflowed, Just (advanceCursor seg i g'))
countFits = fits 0
where
fits n w = \case
[] -> n
(_, cw) : rest
| w + cw <= maxWidth + epsilon -> fits (n + 1) (w + cw) rest
| otherwise -> n
advanceCursor seg i g'
| g' >= Text.length seg.text = Cursor (i + 1) 0
| otherwise = Cursor i g'
data Candidate = Candidate
{ to :: Cursor
, width :: Float
, end :: LineEnd
, next :: Cursor
}
epsilon :: Float
epsilon = 1e-4
materializeSlices :: PreparedText -> LineRange -> [LineSlice]
materializeSlices prepared line = mergeSlices $ mapMaybe slice [line.from.segment .. line.to.segment] <> hyphen
where
segs = prepared.segments
hyphen =
[ LineSlice
{ text = "-"
, style = seg.style
, width = seg.width
, atom = False
}
| line.ended == Hyphenated
, let seg = segs Vector.! line.to.segment
]
slice j
| j >= Vector.length segs = Nothing
| otherwise =
let
seg = segs Vector.! j
startG = if j == line.from.segment then line.from.grapheme else 0
endG = if j == line.to.segment then line.to.grapheme else Text.length seg.text
piece = Text.take (endG - startG) (Text.drop startG seg.text)
pieceWidth
| startG == 0 && endG == Text.length seg.text =
seg.width
| otherwise =
sum . map snd $
takeChars (endG - startG) $
dropChars startG seg.graphemeWidths
in
case seg.kind of
SoftHyphen -> Nothing
HardBreak -> Nothing
ZeroWidthBreak -> Nothing
_
| Text.null piece -> Nothing
| otherwise ->
Just
LineSlice
{ text = piece
, style = seg.style
, width = pieceWidth
, atom = seg.kind == Atomic
}
data LineSlice = LineSlice
{ text :: Text
, style :: Int
, width :: Float
, atom :: Bool
}
deriving stock (Eq, Show)
mergeSlices :: [LineSlice] -> [LineSlice]
mergeSlices = \case
[] -> []
a : b : rest
| not a.atom
, not b.atom
, a.style == b.style ->
mergeSlices $
LineSlice
{ text = a.text <> b.text
, style = a.style
, width = a.width + b.width
, atom = False
}
: rest
a : rest ->
a : mergeSlices rest
materializeLineRange :: PreparedText -> LineRange -> Text
materializeLineRange prepared line = visible <> suffix
where
segs = prepared.segments
suffix = if line.ended == Hyphenated then "-" else ""
visible = Text.concat (mapMaybe piece [line.from.segment .. line.to.segment])
piece j
| j >= Vector.length segs = Nothing
| otherwise =
let
seg = segs Vector.! j
startG = if j == line.from.segment then line.from.grapheme else 0
endG = if j == line.to.segment then line.to.grapheme else Text.length seg.text
in
case seg.kind of
SoftHyphen -> Nothing
HardBreak -> Nothing
ZeroWidthBreak -> Nothing
_ -> Just (Text.take (endG - startG) (Text.drop startG seg.text))
layoutStats :: [LineRange] -> LayoutStats
layoutStats = foldl' step LayoutStats{lineCount = 0, maxLineWidth = 0}
where
step stats line =
LayoutStats
{ lineCount = stats.lineCount + 1
, maxLineWidth = max stats.maxLineWidth line.width
}
data LayoutStats = LayoutStats
{ lineCount :: Int
, maxLineWidth :: Float
}
deriving stock (Eq, Show)
lineStretch :: PreparedText -> LineRange -> LineStretch
lineStretch prepared line = LineStretch{spaces = length inside, width = sum inside}
where
segs = prepared.segments
upper
| line.to.grapheme == 0 = line.to.segment - 1
| otherwise = line.to.segment
inside =
[ seg.width
| j <- [line.from.segment .. min upper (Vector.length segs - 1)]
, let seg = segs Vector.! j
, seg.kind == Space
]
data LineStretch = LineStretch
{ spaces :: Int
, width :: Float
}
deriving stock (Eq, Show)
shrinkwrap :: PreparedText -> Float -> Float
shrinkwrap prepared maxWidth = go (40 :: Int) 0 maxWidth
where
countAt w = (layoutStats (layoutGreedy prepared w)).lineCount
target = countAt maxWidth
go k lo hi
| k <= 0 = (layoutStats (layoutGreedy prepared hi)).maxLineWidth
| countAt mid <= target = go (k - 1) lo mid
| otherwise = go (k - 1) mid hi
where
mid = (lo + hi) / 2
dropChars :: Int -> [(Int, Float)] -> [(Int, Float)]
dropChars n xs
| n <= 0 = xs
| otherwise = case xs of
[] -> []
(len, _) : rest -> dropChars (n - len) rest
takeChars :: Int -> [(Int, Float)] -> [(Int, Float)]
takeChars n xs
| n <= 0 = []
| otherwise = case xs of
[] -> []
x@(len, _) : rest -> x : takeChars (n - len) rest