packages feed

hpdft-0.4.6.0: src/PDF/Layout.hs

{-# LANGUAGE OverloadedStrings #-}

{-|
Module      : PDF.Layout
Description : Line and paragraph reconstruction from interpreted page items
License     : MIT

Turns 'PDF.Interpret' glyph runs into lines and paragraphs. Most callers use
'PDF.Text' or 'PDF.Page' instead of calling these functions directly.

@example
import PDF.Layout (LayoutOptions(..), defaultLayoutOptions)

opts :: LayoutOptions
opts = defaultLayoutOptions { optRuby = True, optFootnotes = False }
-}
module PDF.Layout
  ( Rect(..)
  , PageItem(..)
  , Line(..)
  , LayoutOptions(..)
  , defaultLayoutOptions
  , needsAozoraBar
  , aozoraRuby
  , layoutParagraphs
  , layoutParagraphsWith
  , layoutPageText
  , layoutPageTextWith
  , layoutDocument
  , layoutDocumentWith
  , layoutDocumentFromPageLines
  , pageLinesRaw
  , stripHeadersFooters
  , linesFromGlyphs
  , sortLinesByReadingOrder
  , joinParaLines
  , intraLineSpace
  , joinGlyphsRun
  , pageItemLines
  , pageItemParagraphGroups
  , forcePageLines
  ) where

import Control.DeepSeq (force)

import PDF.Interpret (Glyph(..), Rect(..), PageItem(..))

import Data.Char (isDigit, isSpace, ord)
import Data.List (foldl', maximumBy, partition, sort, sortBy)
import Data.Maybe (listToMaybe, mapMaybe)
import Data.Ord (Down(..), comparing)
import qualified Data.DList as DL
import qualified Data.Map as M
import qualified Data.Set as S
import qualified Data.Text as T

-- Footnote bodies continuing onto the following page are not merged.
-- | Layout options shared by geometry extraction, page APIs, and diff.
data LayoutOptions = LayoutOptions
  { optFootnotes :: Bool
    -- ^ Inline footnote bodies as @\<footnote\>@ tags in paragraph text.
  , optRuby      :: Bool
    -- ^ Embed ruby in Aozora bunko notation (@《…》@, @|@ for mixed-script bases).
  } deriving (Eq, Show)

-- | Default layout: no footnote tags, no ruby.
defaultLayoutOptions :: LayoutOptions
defaultLayoutOptions = LayoutOptions {optFootnotes = False, optRuby = False}

layoutPageText :: [PageItem] -> T.Text
layoutPageText = layoutPageTextWith defaultLayoutOptions

layoutPageTextWith :: LayoutOptions -> [PageItem] -> T.Text
layoutPageTextWith opts items = formatParagraphs (layoutParagraphsWith opts items)

layoutDocument :: [[PageItem]] -> T.Text
layoutDocument = layoutDocumentWith defaultLayoutOptions

layoutDocumentWith :: LayoutOptions -> [[PageItem]] -> T.Text
layoutDocumentWith opts pages =
  layoutDocumentFromPageLines opts (map pageLinesRaw pages)

layoutDocumentFromPageLines :: LayoutOptions -> [PageLines] -> T.Text
layoutDocumentFromPageLines opts layouts =
  formatParagraphs (documentParagraphsFromPageLines opts layouts)

formatParagraphs :: [T.Text] -> T.Text
formatParagraphs ps =
  if null ps
  then "\n"
  else T.intercalate "\n\n" ps `T.append` "\n"

layoutParagraphs :: [PageItem] -> [T.Text]
layoutParagraphs = layoutParagraphsWith defaultLayoutOptions

layoutParagraphsWith :: LayoutOptions -> [PageItem] -> [T.Text]
layoutParagraphsWith opts items =
  case applyFootnotes opts (applyRuby opts (pageLinesRaw items)) of
    PageFallback ps -> ps
    PageNormal wmode graphics bounds ls ->
      map joinParaLines (groupParagraphs wmode graphics bounds ls)

pageItemLines :: LayoutOptions -> [PageItem] -> [Line]
pageItemLines opts items =
  case applyFootnotes opts (applyRuby opts (pageLinesRaw items)) of
    PageFallback _ -> []
    PageNormal _ _ _ ls -> ls

pageItemParagraphGroups :: LayoutOptions -> [PageItem] -> [[Line]]
pageItemParagraphGroups opts items =
  case applyFootnotes opts (applyRuby opts (pageLinesRaw items)) of
    PageFallback ps -> replicate (length ps) []
    PageNormal wmode graphics bounds ls ->
      groupParagraphs wmode graphics bounds ls

documentParagraphs :: LayoutOptions -> [[PageItem]] -> [T.Text]
documentParagraphs opts pages =
  documentParagraphsFromPageLines opts (map pageLinesRaw pages)

documentParagraphsFromPageLines :: LayoutOptions -> [PageLines] -> [T.Text]
documentParagraphsFromPageLines opts layouts =
  let n = length layouts
      stripped = applyHeaderFooterStrip n layouts
      final = map (applyFootnotes opts . applyRuby opts) stripped
  in finalizeDoc $ foldl' processPage (DL.empty, []) final
  where
    continuePage done pageGroups =
      case reverse pageGroups of
        [] -> (done, [])
        lastG : restRev ->
          ( done <> DL.fromList (map joinParaLines (reverse restRev))
          , lastG
          )

    processPage (done, pending) (PageFallback ps) =
      ( done <> DL.fromList (finalize pending ++ map T.strip ps)
      , []
      )

    processPage (done, pending) (PageNormal wmode graphics bounds ls) =
      let pageGroups = groupParagraphs wmode graphics bounds ls
          pageMinInline =
            if null ls
            then 0
            else minimum (map lineInlineStart ls)
      in case (pending, pageGroups) of
        ([], _) -> continuePage done pageGroups
        (ps, []) -> (done, ps)
        (ps, g : gs) ->
          case g of
            firstLine : _ ->
              let paraSoFar = joinParaLines ps
                  lastLine = case reverse ps of
                    l : _ -> l
                    [] -> firstLine
              in if pageBoundaryBreak paraSoFar firstLine pageMinInline lastLine firstLine
                 then continuePage (done `DL.snoc` paraSoFar) (g : gs)
                 else case reverse gs of
                        [] -> (done, ps ++ g)
                        lastG : restRev ->
                          ( done <>
                              ( DL.fromList
                                  ( joinParaLines (ps ++ g)
                                  : map joinParaLines (reverse restRev)
                                  )
                              )
                          , lastG
                          )
            [] -> (done, ps)

    finalizeDoc (done, pending) = DL.toList done ++ finalize pending

    finalize [] = []
    finalize ps = [joinParaLines ps]

applyHeaderFooterStrip :: Int -> [PageLines] -> [PageLines]
applyHeaderFooterStrip n layouts =
  let normalPairs = [(i, ls) | (i, PageNormal _ _ _ ls) <- zip [0 ..] layouts]
      strippedNormals = stripHeadersFooters n (map snd normalPairs)
      strippedMap = M.fromList (zip (map fst normalPairs) strippedNormals)
  in [ case layout of
         PageFallback ps -> PageFallback ps
         PageNormal w g b ls ->
           PageNormal w g b (M.findWithDefault ls i strippedMap)
     | (i, layout) <- zip [0 ..] layouts
     ]

data PageLines
  = PageFallback [T.Text]
  | PageNormal Int [Rect] (Double, Double) [Line]
  deriving (Show)

-- | Force layout fields used after interpretation (text, coords, markers).
-- Source 'Glyph' values may retain unevaluated 'FontInfo' functions; 'PageLines'
-- does not reference them.
forcePageLines :: PageLines -> ()
forcePageLines (PageFallback ps) =
  foldr (\t () -> force t `seq` ()) () ps
forcePageLines (PageNormal wmode graphics bounds ls) =
  wmode `seq`
  foldr forceRect () graphics `seq`
  bounds `seq`
  foldr forceLine () ls `seq`
  ()

forceRect :: Rect -> () -> ()
forceRect r () =
  rectX0 r `seq` rectY0 r `seq` rectX1 r `seq` rectY1 r `seq` ()

forceLine :: Line -> () -> ()
forceLine l () =
  lineBaseline l `seq`
  lineInlineStart l `seq`
  lineInlineEnd l `seq`
  lineSize l `seq`
  lineFirstInline l `seq`
  lineWMode l `seq`
  force (lineText l) `seq`
  foldr (\(i, t) () -> i `seq` force t `seq` ()) () (lineMarkers l) `seq`
  lineLastSuper l `seq`
  ()

applyFootnotes :: LayoutOptions -> PageLines -> PageLines
applyFootnotes opts page =
  case page of
    PageNormal 0 graphics bounds ls
      | optFootnotes opts ->
          PageNormal 0 graphics bounds (inlineFootnotes graphics ls)
    _ -> page

applyRuby :: LayoutOptions -> PageLines -> PageLines
applyRuby opts page =
  case page of
    PageNormal wmode _ bounds ls ->
      PageNormal wmode [] bounds (mergeInterleavedRubyLines wmode (optRuby opts) ls)
    _ -> page

-- Aozora bunko ruby: fullwidth 《》 and optional | before mixed-script bases.
aozoraRuby :: T.Text -> T.Text -> T.Text
aozoraRuby base ruby =
  let prefix = if needsAozoraBar base then "\65372" else T.empty  -- |
  in base `T.append` prefix `T.append` "\12298" `T.append` ruby `T.append` "\12299"  -- 《》

needsAozoraBar :: T.Text -> Bool
needsAozoraBar t =
  let cats = S.fromList (mapMaybe scriptCategory (T.unpack t))
  in S.size cats >= 2

data ScriptCat = CatHiragana | CatKatakana | CatCJK | CatLatin | CatOther
  deriving (Eq, Ord, Show)

scriptCategory :: Char -> Maybe ScriptCat
scriptCategory c =
  let cp = ord c
  in if cp >= 0x3041 && cp <= 0x309F
     then Just CatHiragana
     else if cp >= 0x30A1 && cp <= 0x30FF
          then Just CatKatakana
          else if (cp >= 0x4E00 && cp <= 0x9FFF)
               || (cp >= 0x3400 && cp <= 0x4DBF)
               || (cp >= 0xF900 && cp <= 0xFAFF)
               then Just CatCJK
               else if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
                    then Just CatLatin
                    else if isSpace c
                         then Nothing
                         else Just CatOther

pairRubyLines :: Int -> (Double, Double) -> [Line] -> [Line]
pairRubyLines wmode _ = mergeInterleavedRubyLines wmode True

isRubyLine :: Int -> Double -> [Line] -> Line -> Bool
isRubyLine wmode bodySize ls l =
  lineSize l <= 0.85 * bodySize
  && not (T.null (T.strip (lineText l)))
  && any (rubyAlignsWithParent wmode l) (filter isBodyLine ls)
  where
    isBodyLine b = lineSize b > 0.85 * bodySize

bodyMedianSize :: [Line] -> Double
bodyMedianSize ls =
  let sizes = map lineSize ls
      med = medianOf sizes
      bodySizes = [lineSize l | l <- ls, lineSize l > 0.85 * med]
  in if null bodySizes then med else medianOf bodySizes

baselineClose :: Double -> Line -> Line -> Bool
baselineClose bodySize a b =
  abs (lineBaseline a - lineBaseline b) <= 0.4 * bodySize

shortBodyLine :: Line -> Bool
shortBodyLine l = T.length (T.strip (lineText l)) <= 2

firstCharText :: T.Text -> T.Text
firstCharText t =
  case T.uncons (T.strip t) of
    Nothing -> T.empty
    Just (c, _) -> T.singleton c

clusterBaseText :: [(Line, Line)] -> T.Text
clusterBaseText pairs =
  let bs = map fst pairs
  in T.concat
       [ if length bs == 1
         then T.strip (lineText b)
         else if i == length bs - 1 && not (shortBodyLine b)
              then firstCharText (lineText b)
              else T.strip (lineText b)
       | (i, b) <- zip [0 ..] bs
       ]

clusterSuffixText :: [(Line, Line)] -> T.Text
clusterSuffixText pairs =
  case reverse (map fst pairs) of
    b : _ | not (shortBodyLine b) ->
      let t = T.strip (lineText b)
      in if T.null t then T.empty else T.drop 1 t
    _ -> T.empty

clusterRubyText :: [(Line, Line)] -> T.Text
clusterRubyText = T.concat . map (T.strip . lineText . snd)

clusterContinuation :: Line -> Line -> Bool
clusterContinuation prev cur =
  baselineClose (lineSize cur) prev cur
  && lineInlineStart cur - lineInlineEnd prev <= 2 * lineSize cur

removeRubyLine :: Line -> [Line] -> [Line]
removeRubyLine r = filter (not . sameRubyLine r)

sameRubyLine :: Line -> Line -> Bool
sameRubyLine a b =
  lineBaseline a == lineBaseline b
  && lineInlineStart a == lineInlineStart b
  && lineText a == lineText b

findRubyForBody :: Int -> [Line] -> Line -> Maybe Line
findRubyForBody wmode rubyLs body =
  case [ r
       | r <- rubyLs
       , rubyAlignsWithParent wmode r body
       ] of
    [] -> Nothing
    rs -> Just (maximumBy (comparing (\r -> rubyOverlapFrac wmode r body)) rs)

mergeInterleavedRubyLines :: Int -> Bool -> [Line] -> [Line]
mergeInterleavedRubyLines wmode includeRuby ls
  | null ls = ls
  | otherwise =
      let bodySize = bodyMedianSize ls
          (rubyLs, bodyLs) = partition (isRubyLine wmode bodySize ls) ls
      in if null rubyLs
         then ls
         else mergeBodyBands wmode includeRuby bodySize rubyLs bodyLs

mergeBodyBands :: Int -> Bool -> Double -> [Line] -> [Line] -> [Line]
mergeBodyBands wmode includeRuby bodySize rubyLs bodyLs =
  let bands = groupBodyBaselineBands bodySize (sortLinesByReadingOrder bodyLs)
  in concatMap (mergeOneBand wmode includeRuby bodySize rubyLs) bands

groupBodyBaselineBands :: Double -> [Line] -> [[Line]]
groupBodyBaselineBands _ [] = []
groupBodyBaselineBands bodySize (l:ls) =
  let (same, rest) = span (baselineClose bodySize l) ls
  in (l : same) : groupBodyBaselineBands bodySize rest

mergeOneBand :: Int -> Bool -> Double -> [Line] -> [Line] -> [Line]
mergeOneBand wmode includeRuby bodySize allRuby bodyBand =
  let sorted = sortBy (comparing lineInlineStart) bodyBand
      (segments, _) = foldSegments wmode bodySize allRuby sorted
      repStart = head sorted
      repEnd = last sorted
      txt = renderRubySegments includeRuby segments
  in if T.null txt
     then []
     else [ repStart
              { lineText = txt
              , lineInlineEnd = lineInlineEnd repEnd
              , lineInlineStart = lineInlineStart repStart
              , lineSize = max (lineSize repStart) (lineSize repEnd)
              }
          ]

data RubySegment
  = PlainSeg !Line
  | ClusterSeg [(Line, Line)]
  deriving (Show)

foldSegments :: Int -> Double -> [Line] -> [Line] -> ([RubySegment], [Line])
foldSegments wmode bodySize rubyPool =
  go rubyPool
  where
    go pool [] = ([], pool)
    go pool (b:bs) =
      case findRubyForBody wmode pool b of
        Nothing ->
          let (plain, rest) = span (\l' -> case findRubyForBody wmode pool l' of Nothing -> True; _ -> False) (b:bs)
              (rest', pool') = go pool (drop (length plain) (b:bs))
          in (map PlainSeg plain ++ rest', pool')
        Just r ->
          let (cluster, restBs, pool') = spanCluster wmode bodySize (removeRubyLine r pool) r b bs
              (rest', pool'') = go pool' restBs
          in (ClusterSeg cluster : rest', pool'')

spanCluster :: Int -> Double -> [Line] -> Line -> Line -> [Line]
           -> ([(Line, Line)], [Line], [Line])
spanCluster wmode bodySize pool r b bs =
  go pool [(b, r)] bs
  where
    go rp pairs (b':bs') =
      case findRubyForBody wmode rp b' of
        Just r' | clusterContinuation (fst (last pairs)) b' ->
          go (removeRubyLine r' rp) ((b', r') : pairs) bs'
        _ -> (reverse pairs, b' : bs', rp)
    go rp pairs [] = (reverse pairs, [], rp)

renderRubySegments :: Bool -> [RubySegment] -> T.Text
renderRubySegments includeRuby =
  T.concat . map renderSeg
  where
    renderSeg (PlainSeg l) = T.strip (lineText l)
    renderSeg (ClusterSeg pairs) =
      let base = clusterBaseText pairs
          ruby = clusterRubyText pairs
          suffix = clusterSuffixText pairs
          marked =
            if includeRuby
            then aozoraRuby base ruby
            else base
      in marked `T.append` suffix

rubyAlignsWithParent :: Int -> Line -> Line -> Bool
rubyAlignsWithParent wmode ruby parent =
  let bodySize = lineSize parent
      offset = rubyOffset wmode parent ruby
      overlap = rubyOverlapFrac wmode ruby parent
  in lineSize ruby <= 0.85 * bodySize
     && offset > 0.15 * bodySize && offset <= 1.2 * bodySize
     && overlap >= 0.2

rubyOverlapFrac :: Int -> Line -> Line -> Double
rubyOverlapFrac _ ruby parent =
  let rLo = min (lineInlineStart ruby) (lineInlineEnd ruby)
      rHi = max (lineInlineStart ruby) (lineInlineEnd ruby)
      bLo = min (lineInlineStart parent) (lineInlineEnd parent)
      bHi = max (lineInlineStart parent) (lineInlineEnd parent)
      lo = max rLo bLo
      hi = min rHi bHi
      overlap = max 0 (hi - lo)
      span = max (rHi - rLo) 1
  in overlap / span

rubyOffset :: Int -> Line -> Line -> Double
rubyOffset wmode parent ruby =
  if wmode == 1
  then lineBaseline parent - lineBaseline ruby
  else lineBaseline ruby - lineBaseline parent

inlineOverlapFrac :: Int -> Line -> Line -> Double
inlineOverlapFrac _ a b =
  let aLo = min (lineInlineStart a) (lineInlineEnd a)
      aHi = max (lineInlineStart a) (lineInlineEnd a)
      bLo = min (lineInlineStart b) (lineInlineEnd b)
      bHi = max (lineInlineStart b) (lineInlineEnd b)
      lo = max aLo bLo
      hi = min aHi bHi
      overlap = max 0 (hi - lo)
      span = max (bHi - bLo) 1
  in overlap / span

-- Footnote heuristic (horizontal pages only): small-size lines in the
-- bottom band (or under a bottom horizontal rule) that start with a
-- marker such as †2 form footnote blocks; matching superscript markers
-- in the body are replaced with <footnote>...</footnote>.
inlineFootnotes :: [Rect] -> [Line] -> [Line]
inlineFootnotes graphics ls
  | null ls = ls
  | otherwise =
      let bodySize = medianOf (map lineSize ls)
          (lo, hi) = pageBaselineExtent ls
          bandTop = lo + 0.35 * (hi - lo)
          ruleYs =
            [ max (rectY0 r) (rectY1 r)
            | r <- graphics
            , rectHeight r < 1
            , rectWidth r >= 40
            , min (rectY0 r) (rectY1 r) <= bandTop
            ]
          isSmall l = lineSize l <= 0.85 * bodySize
          inRegion l =
            isSmall l
            && (lineBaseline l <= bandTop || any (> lineBaseline l) ruleYs)
          tagged = [(inRegion l, l) | l <- ls]
          regionLines = [l | (True, l) <- tagged]
          blocks = footnoteBlocks regionLines
          (consumedIdx, replaceBody) = matchAnchors blocks [l | (False, l) <- tagged]
          consumedLines = S.fromList
            [ i
            | (bi, (_, _, lineIdxs)) <- zip [0 :: Int ..] blocks
            , bi `S.member` consumedIdx
            , i <- lineIdxs
            ]
          keep (i, (inR, l))
            | inR = not (regionIndexOf i `S.member` consumedLines)
            | otherwise = True
          regionIndexOf i =
            length [() | (j, (True, _)) <- zip [0 ..] tagged, j < i]
      in [ if inR then l else replaceBody l
         | (i, (inR, l)) <- zip [0 :: Int ..] tagged
         , keep (i, (inR, l))
         ]

-- Blocks: (marker key, body text, indexes into the region line list).
footnoteBlocks :: [Line] -> [(T.Text, T.Text, [Int])]
footnoteBlocks regionLines = go (zip [0 ..] regionLines)
  where
    go [] = []
    go ((i, l) : rest) =
      case blockStart l of
        Nothing -> go rest
        Just (key, firstText) ->
          let (cont, rest') = break (\(_, l') -> blockStart l' /= Nothing) rest
              bodyLines = firstText : map (T.strip . lineText . snd) cont
              body = T.strip (foldl' cjkJoin T.empty bodyLines)
          in (key, body, i : map fst cont) : go rest'

    cjkJoin a b
      | T.null a = b
      | T.null b = a
      | otherwise =
          let sep = paraJoinSep (T.stripEnd a) (T.stripStart b)
          in if T.null sep
             then a `T.append` b
             else a `T.append` sep `T.append` b

blockStart :: Line -> Maybe (T.Text, T.Text)
blockStart l =
  case [mt | (0, mt) <- lineMarkers l] of
    mt : _ | Just key <- markerKey mt ->
      Just (key, T.strip (T.drop (T.length mt) (lineText l)))
    _ ->
      let t = T.stripStart (lineText l)
      in case T.uncons t of
           Just (c, rest)
             | c `elem` ("\8224\8225*\8251" :: String) ->
                 let (ds, rest') = T.span isAsciiDigit rest
                 in if not (T.null ds) && T.length ds <= 3
                    then Just (T.cons c ds, T.strip rest')
                    else Nothing
           _ -> Nothing

-- Anchor pass: returns consumed block indexes and a body-line rewriter.
matchAnchors :: [(T.Text, T.Text, [Int])] -> [Line] -> (S.Set Int, Line -> Line)
matchAnchors blocks bodyLines =
  let anchors =
        [ key
        | l <- bodyLines
        , (_, mt) <- lineMarkers l
        , Just key <- [markerKey mt]
        ]
      assign consumed [] = consumed
      assign consumed (key : rest) =
        case [ bi
             | (bi, (bkey, _, _)) <- zip [0 ..] blocks
             , bkey == key
             , not (bi `S.member` S.map fst consumed)
             ] of
          bi : _ -> assign (S.insert (bi, key) consumed) rest
          [] -> assign consumed rest
      consumedPairs = assign S.empty anchors
      consumedIdx = S.map fst consumedPairs
      bodyOf key =
        case [ (bi, b)
             | (bi, (bkey, b, _)) <- zip [0 ..] blocks
             , bkey == key
             , bi `S.member` consumedIdx
             ] of
          (_, b) : _ -> Just b
          [] -> Nothing
      rewrite l =
        let (txt', finalPos, _) =
              foldl' step (T.empty, 0, S.empty) (lineMarkers l)
            step (acc, pos, used) (off, mt) =
              let pre = T.take (off - pos) (T.drop pos (lineText l))
                  after = off + T.length mt
              in case markerKey mt of
                   Just key
                     | not (key `S.member` used)
                     , Just b <- bodyOf key ->
                         ( T.concat [acc, pre, "<footnote>", b, "</footnote>"]
                         , after
                         , S.insert key used
                         )
                   _ -> (acc `T.append` pre `T.append` mt, after, used)
            rest = T.drop finalPos (lineText l)
        in if null (lineMarkers l)
           then l
           else l {lineText = txt' `T.append` rest, lineMarkers = []}
  in (consumedIdx, rewrite)

markerKey :: T.Text -> Maybe T.Text
markerKey mt =
  let s = T.filter (not . isSpace) mt
  in case T.uncons s of
       Just (c, rest)
         | c `elem` ("\8224\8225*\8251" :: String)
         , digits rest -> Just s
       _ | digits s -> Just s
       _ -> Nothing
  where
    digits d = not (T.null d) && T.length d <= 3 && T.all isAsciiDigit d

isAsciiDigit :: Char -> Bool
isAsciiDigit c = c >= '0' && c <= '9'

medianOf :: [Double] -> Double
medianOf xs =
  case sort xs of
    [] -> 0
    sorted ->
      let n = length sorted
          mid = n `div` 2
      in if odd n
         then sorted !! mid
         else (sorted !! (mid - 1) + sorted !! mid) / 2

pageLinesRaw :: [PageItem] -> PageLines
pageLinesRaw items =
  let glyphs = [g | ItemGlyph g <- items]
      graphics = [r | ItemGraphic r <- items]
  in if null glyphs
     then PageFallback []
     else if fallbackNeeded glyphs
          then PageFallback [fallbackText glyphs]
          else let visible = filterPageGlyphs glyphs
                   wmode = dominantWMode visible
                   pageBounds = pageExtents visible
                   ls = map fixDingbatBulletLine (buildLines visible)
               in PageNormal wmode graphics pageBounds ls

linesFromGlyphs :: [Glyph] -> [Line]
linesFromGlyphs = buildLines

-- RTL horizontal text is not supported; only LTR horizontal and vertical CJK.
sortLinesByReadingOrder :: [Line] -> [Line]
sortLinesByReadingOrder [] = []
sortLinesByReadingOrder ls =
  let (w0, w1) = partition ((== 0) . lineWMode) ls
      sortHoriz = sortOn (\l -> (Down (lineBaseline l), lineFirstInline l))
      sortVert = sortOn (\l -> (Down (lineBaseline l), Down (lineFirstInline l)))
  in if null w0 || null w1
     then if null w1 then sortHoriz w0 else sortVert w1
     else sortHoriz w0 ++ sortVert w1
  where
    sortOn f = sortBy (comparing f)

stripHeadersFooters :: Int -> [[Line]] -> [[Line]]
stripHeadersFooters pageCount pagesLines =
  let threshold =
        let raw = ceiling (0.2 * fromIntegral pageCount :: Double) :: Int
        in max 3 (min raw 5)
      pageInfos =
        [ (ls, pageBaselineExtent ls)
        | ls <- pagesLines
        , not (null ls)
        ]
      topCounts = countBandCores Top pageInfos
      bottomCounts = countBandCores Bottom pageInfos
      repeatedTop = repeatedCores threshold pageCount topCounts
      repeatedBottom = repeatedCores threshold pageCount bottomCounts
  in map (filterLine repeatedTop repeatedBottom) pagesLines
  where
    filterLine repTop repBottom ls
      | length ls <= 2 =
          let extent = pageBaselineExtent ls
          in if any (isRemoved repTop repBottom extent) ls
             then filter (not . isRemoved repTop repBottom extent) ls
             else ls
      | otherwise =
          let extent = pageBaselineExtent ls
          in filter (not . isRemoved repTop repBottom extent) ls

    isRemoved repTop repBottom extent l =
      let band = lineBand extent l
          norm = normalizeHeaderFooterText (lineText l)
      in shouldRemove band norm pageCount repTop repBottom

    repeatedCores thresh n counts
      | n >= 3 =
          S.fromList [ core | (core, c) <- M.toList counts, c >= thresh ]
      | otherwise = S.empty

countBandCores :: Band -> [([Line], (Double, Double))] -> M.Map T.Text Int
countBandCores band pageInfos =
  M.fromListWith (+)
    [ (headerFooterCore (lineText l), 1)
    | (ls, extent) <- pageInfos
    , l <- ls
    , lineBand extent l == band
    ]

headerFooterCore :: T.Text -> T.Text
headerFooterCore = T.filter (/= '#') . normalizeHeaderFooterText

shouldRemove :: Band -> T.Text -> Int -> S.Set T.Text -> S.Set T.Text -> Bool
shouldRemove band norm pageCount repTop repBottom
  | band == Middle = False
  | isBarePageNumber norm = pageCount >= 2
  | otherwise =
      let core = headerFooterCore norm
          repeated = case band of
            Top -> repTop
            Bottom -> repBottom
            Middle -> S.empty
      in S.member core repeated

data Band = Top | Bottom | Middle
  deriving (Eq, Ord, Show)

lineBand :: (Double, Double) -> Line -> Band
lineBand (lo, hi) l =
  let bl = lineBaseline l
      span = hi - lo
  in if span <= 0
     then Middle
     else if bl >= hi - 0.15 * span
          then Top
          else if bl <= lo + 0.15 * span
               then Bottom
               else Middle

pageBaselineExtent :: [Line] -> (Double, Double)
pageBaselineExtent ls =
  let baselines = map lineBaseline ls
  in (minimum baselines, maximum baselines)

normalizeHeaderFooterText :: T.Text -> T.Text
normalizeHeaderFooterText =
  replaceRomanNumerals . replaceAsciiDigits . T.filter (not . isSpace)

replaceAsciiDigits :: T.Text -> T.Text
replaceAsciiDigits t =
  let chars = T.unpack t
      (out, _) = foldl' go ([], False) chars
  in T.pack (reverse out)
  where
    go (acc, inRun) c
      | c >= '0' && c <= '9' =
          if inRun then (acc, True) else ('#' : acc, True)
      | otherwise = (c : acc, False)

replaceRomanNumerals :: T.Text -> T.Text
replaceRomanNumerals t = T.pack (go (T.unpack t) [])
  where
    go [] acc = reverse acc
    go (c : cs') acc =
      let (tok, rest) = span isRomanDigit (c : cs')
      in if not (null tok) && length tok <= 7
         then go rest ('#' : acc)
         else go cs' (c : acc)

isRomanDigit :: Char -> Bool
isRomanDigit c = c `elem` ("ivxlcdmIVXLCDM" :: String)

isBarePageNumber :: T.Text -> Bool
isBarePageNumber t =
  not (T.null t)
  && T.any (=='#') t
  && T.all (\c -> c == '#' || c `elem` ("-/." :: String)) t

pageBoundaryBreak :: T.Text -> Line -> Double -> Line -> Line -> Bool
pageBoundaryBreak paraSoFar firstLine pageMinInline lastLine firstLine' =
  endsWithTerminal paraSoFar
  || indentPageBreak pageMinInline firstLine
  || abs (lineSize firstLine' - lineSize lastLine)
       > 0.15 * max (lineSize firstLine') (lineSize lastLine)
  || lineWMode lastLine /= lineWMode firstLine'

fallbackNeeded :: [Glyph] -> Bool
fallbackNeeded glyphs =
  let n = length glyphs
      usable = length (filter usableGlyph glyphs)
  in n == 0 || fromIntegral usable / fromIntegral n < 0.7

fallbackText :: [Glyph] -> T.Text
fallbackText = T.intercalate "\n" . map glyphText

usableGlyph :: Glyph -> Bool
usableGlyph g =
  glyphSize g > 0
  && not (isNaN (glyphX g) || isInfinite (glyphX g))
  && not (isNaN (glyphY g) || isInfinite (glyphY g))

-- | Drop coordinate outliers (e.g. footnote digits emitted far off-page).
filterPageGlyphs :: [Glyph] -> [Glyph]
filterPageGlyphs glyphs =
  let horizVis = [g | g <- glyphs, glyphWMode g == 0, glyphY g >= 0]
      vert = [g | g <- glyphs, glyphWMode g == 1]
      horizBand = baselineBand glyphY glyphSize horizVis
      vertBand = baselineBand (baselineOf 1) glyphSize vert
  in filter (glyphInBand horizBand vertBand) glyphs
  where
    glyphInBand hBand vBand g
      | glyphWMode g == 1 = inBand (baselineOf 1) vBand g
      | otherwise = inBand glyphY hBand g

    inBand measure band g =
      let v = measure g
      in v >= 0 && case band of
           Nothing -> True
           Just (lo, hi) -> v >= lo && v <= hi

    baselineBand measure sizeOf gs =
      let ys = sort (map measure gs)
      in if length ys < 4
         then Nothing
         else let q1 = quantile 0.25 ys
                  q3 = quantile 0.75 ys
                  iqr = q3 - q1
                  medSize = medianOf (map sizeOf gs)
                  spread = max (max 1 iqr) (1.2 * medSize)
                  pad = 3 * spread
              in Just (q1 - pad, q3 + pad)

    quantile q xs =
      let n = length xs
          i = min (n - 1) (max 0 (truncate (q * fromIntegral (n - 1))))
      in xs !! i

dominantWMode :: [Glyph] -> Int
dominantWMode glyphs =
  let counts = foldr (\g m -> M.insertWith (+) (glyphWMode g) 1 m) M.empty glyphs
  in if M.null counts
     then 0
     else fst (maximumBy (comparing snd) (M.toList counts))

pageExtents :: [Glyph] -> (Double, Double)
pageExtents glyphs =
  let xs = concatMap (\g -> [glyphX g, glyphX g + glyphWidth g]) glyphs
      ys = map glyphY glyphs
  in (max 1 (maximum xs - minimum xs), max 1 (maximum ys - minimum ys))

baselineOf :: Int -> Glyph -> Double
baselineOf wmode g = if wmode == 1 then glyphX g else glyphY g

inlineStartOf :: Int -> Glyph -> Double
inlineStartOf wmode g = if wmode == 1 then glyphY g else glyphX g

inlineEndOf :: Int -> Glyph -> Double
inlineEndOf wmode g =
  if wmode == 1
  then glyphY g - glyphWidth g
  else glyphX g + glyphWidth g

data Line = Line
  { lineBaseline    :: !Double
  , lineInlineStart :: !Double
  , lineInlineEnd   :: !Double
  , lineSize        :: !Double
  , lineFirstInline :: !Double
  , lineWMode       :: !Int
  , lineText        :: !T.Text
  , lineMarkers     :: [(Int, T.Text)]
  , lineLastSuper   :: !Bool
  } deriving (Show)

buildLines :: [Glyph] -> [Line]
buildLines = reverse . foldl' go []
  where
    go [] g = [newLine g]
    go (l:ls) g
      | glyphWMode g /= lineWMode l = newLine g : l : ls
      | superAttach = mergeSuper l g : ls
      | rebaseAttach = mergeRebase l g : ls
      | abs d <= 0.4 * max (glyphSize g) (lineSize l) = mergeGlyph l g : ls
      | otherwise = newLine g : l : ls
      where
        d = baselineOf (lineWMode l) g - lineBaseline l
        gap = inlineStartOf (lineWMode l) g - lineInlineEnd l
        inlineCont refSize = gap >= -0.5 * refSize && gap <= 2.0 * refSize
        superAttach =
          glyphSize g <= 0.92 * lineSize l
          && glyphSize g >= 0.5 * lineSize l
          && inlineCont (lineSize l)
          && ((d > 0.25 * lineSize l && d <= 0.75 * lineSize l)
              || ((-d) > 0.25 * lineSize l && (-d) <= 0.4 * lineSize l))
        rebaseAttach =
          lineSize l <= 0.92 * glyphSize g
          && lineSize l >= 0.5 * glyphSize g
          && inlineCont (glyphSize g)
          && (((-d) > 0.25 * glyphSize g && (-d) <= 0.75 * glyphSize g)
              || (d > 0.25 * glyphSize g && d <= 0.4 * glyphSize g))

newLine :: Glyph -> Line
newLine g =
  Line
    { lineBaseline = baselineOf (glyphWMode g) g
    , lineInlineStart = inlineStartOf (glyphWMode g) g
    , lineInlineEnd = inlineEndOf (glyphWMode g) g
    , lineSize = glyphSize g
    , lineFirstInline = inlineStartOf (glyphWMode g) g
    , lineWMode = glyphWMode g
    , lineText = glyphText g
    , lineMarkers = []
    , lineLastSuper = False
    }

mergeGlyph :: Line -> Glyph -> Line
mergeGlyph line g =
  let w = lineWMode line
      gap = inlineStartOf w g - lineInlineEnd line
      size = max (glyphSize g) (lineSize line)
      space = intraLineSpace gap size (lastChar (lineText line)) (firstChar (glyphText g))
  in line
       { lineInlineEnd = inlineEndOf w g
       , lineInlineStart = min (lineInlineStart line) (inlineStartOf w g)
       , lineSize = size
       , lineText = lineText line `T.append` space `T.append` glyphText g
       , lineLastSuper = False
       }

mergeSuper :: Line -> Glyph -> Line
mergeSuper line g =
  let w = lineWMode line
      gap = inlineStartOf w g - lineInlineEnd line
      space = intraLineSpace gap (lineSize line) (lastChar (lineText line)) (firstChar (glyphText g))
      offset = T.length (lineText line) + T.length space
      markers =
        if lineLastSuper line
        then case reverse (lineMarkers line) of
          (off, mt) : restRev ->
            reverse ((off, mt `T.append` space `T.append` glyphText g) : restRev)
          [] -> [(offset, glyphText g)]
        else lineMarkers line ++ [(offset, glyphText g)]
  in line
       { lineInlineEnd = inlineEndOf w g
       , lineInlineStart = min (lineInlineStart line) (inlineStartOf w g)
       , lineText = lineText line `T.append` space `T.append` glyphText g
       , lineMarkers = markers
       , lineLastSuper = True
       }

mergeRebase :: Line -> Glyph -> Line
mergeRebase line g =
  let w = lineWMode line
      gap = inlineStartOf w g - lineInlineEnd line
      space = intraLineSpace gap (glyphSize g) (lastChar (lineText line)) (firstChar (glyphText g))
  in line
       { lineBaseline = baselineOf w g
       , lineSize = glyphSize g
       , lineInlineEnd = inlineEndOf w g
       , lineInlineStart = min (lineInlineStart line) (inlineStartOf w g)
       , lineText = lineText line `T.append` space `T.append` glyphText g
       , lineMarkers = [(0, lineText line)]
       , lineLastSuper = False
       }

joinGlyphsRun :: [Glyph] -> T.Text
joinGlyphsRun [] = T.empty
joinGlyphsRun (g : gs) =
  let (txt, _) = foldl' go (glyphText g, g) gs
  in txt
  where
    go (acc, prev) g' =
      let wmode = glyphWMode g'
          gap = inlineStartOf wmode g' - inlineEndOf wmode prev
          size = max (glyphSize g') (glyphSize prev)
          space = intraLineSpace gap size (lastChar acc) (firstChar (glyphText g'))
      in (acc `T.append` space `T.append` glyphText g', g')

intraLineSpace :: Double -> Double -> Maybe Char -> Maybe Char -> T.Text
intraLineSpace gap size mc nc
  | mc == Just '-' || nc == Just '-' = ""
  | latinAdjacent mc nc, gap >= 0.25 * size = " "
  | gap > 2.0 * size = " "
  | gap > 0.3 * size, not (cjkAdjacent mc nc) = " "
  | otherwise = ""

isLatinLetter :: Char -> Bool
isLatinLetter c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')

latinAdjacent :: Maybe Char -> Maybe Char -> Bool
latinAdjacent (Just a) (Just b) =
  not (isCJK a || isCJK b) && (isLatinLetter a || isLatinLetter b)
latinAdjacent _ _ = False

hyphenContinues :: Char -> Bool
hyphenContinues c = c == '-' || c == '\x00AD'

paraJoinSep :: T.Text -> T.Text -> T.Text
paraJoinSep a' b'
  | not (T.null a') && not (T.null b')
    && isCJK (T.last a') && isCJK (T.head b') = T.empty
  | not (T.null a') && hyphenContinues (T.last a') = T.empty
  | otherwise = " "

lastChar :: T.Text -> Maybe Char
lastChar t = if T.null t then Nothing else Just (T.last t)

firstChar :: T.Text -> Maybe Char
firstChar t = if T.null t then Nothing else Just (T.head t)

cjkAdjacent :: Maybe Char -> Maybe Char -> Bool
cjkAdjacent (Just a) (Just b) = isCJK a && isCJK b
cjkAdjacent _ _ = False

isCJK :: Char -> Bool
isCJK c =
  let cp = ord c
  in (cp >= 0x4E00 && cp <= 0x9FFF)
  || (cp >= 0x3040 && cp <= 0x309F)
  || (cp >= 0x30A0 && cp <= 0x30FF)
  || (cp >= 0x3000 && cp <= 0x303F)
  || (cp >= 0xFF00 && cp <= 0xFFEF)

listMarkerStart :: Line -> Bool
listMarkerStart l =
  let t = T.stripStart (lineText l)
  in letteredMarker t || numberedListMarker t
  where
    letteredMarker t =
      case T.uncons t of
        Just (c, rest) | c >= 'a' && c <= 'z' ->
          case T.uncons (T.stripStart rest) of
            Just ('.', _) -> True
            _ -> False
        _ -> False
    numberedListMarker t =
      case T.uncons t of
        Just (c, rest) | isDigit c ->
          case T.span isDigit t of
            (ds, rest') ->
              not (T.null ds)
              && T.length ds <= 2
              && case T.uncons (T.stripStart rest') of
                   Just ('.', _) -> True
                   _ -> False
        _ -> False

hangWrappedContinuation :: Line -> Line -> Bool
hangWrappedContinuation prev cur =
  lineFirstInline cur > lineFirstInline prev + 0.6 * lineSize prev

afterListHeadingBreak :: Int -> Line -> Line -> [Double] -> Bool
afterListHeadingBreak wmode prev cur gaps =
  listMarkerStart prev
  && not (hangWrappedContinuation prev cur)
  && abs (baselineGap wmode prev cur)
       >= 0.75 * typicalLeading gaps (lineSize cur)

listItemEnd :: Line -> Bool
listItemEnd l =
  let t = T.strip (lineText l)
  in T.isSuffixOf "こと" t || endsWithTerminal t

sameHangListItemBreak :: Int -> Line -> Line -> [Double] -> Bool
sameHangListItemBreak wmode prev cur gaps
  | isCodeLine prev || isCodeLine cur = False
  | not (listItemEnd prev) = False
  | otherwise =
      let gap = abs (baselineGap wmode prev cur)
          typical = typicalLeading gaps (lineSize cur)
          tol = 0.35 * lineSize cur
      in cjkAdjacent (lastChar (lineText prev)) (firstChar (lineText cur))
         && abs (lineFirstInline cur - lineFirstInline prev) <= tol
         && gap >= 0.85 * typical
         && not (hangWrappedContinuation prev cur)

isNumberedCodeLine :: Line -> Bool
isNumberedCodeLine l = numberedCodeStart (T.stripStart (lineText l))

numberedCodeStart :: T.Text -> Bool
numberedCodeStart t =
  case T.uncons t of
    Just (c, rest) | isDigit c ->
      case T.span isDigit t of
        (ds, rest') ->
          not (T.null ds)
          && case T.uncons (T.stripStart rest') of
               Just (codeSep, _)
                 | codeSep == ' ' || codeSep == '.' -> True
               _ -> False
    _ -> False

isCodeLine :: Line -> Bool
isCodeLine l =
  isNumberedCodeLine l
  || (lineSize l <= 7.5 && smallMonospaceLine l && highLatinFraction (lineText l))

smallMonospaceLine :: Line -> Bool
smallMonospaceLine l =
  let t = T.strip (lineText l)
  in not (T.null t)
     && lineSize l > 0
     && T.any isLatinLetter t
     && not (T.any isCJK t)

highLatinFraction :: T.Text -> Bool
highLatinFraction t =
  let chars = filter (not . isSpace) (T.unpack t)
      latin = length (filter isLatinLetter chars)
  in not (null chars) && fromIntegral latin / fromIntegral (length chars) >= 0.5

codeBlockBreak :: Line -> Line -> Bool
codeBlockBreak prev cur =
  isCodeLine cur /= isCodeLine prev

codeIndentSpaces :: Double -> Double -> Line -> T.Text
codeIndentSpaces minX charW l =
  let offset = max 0 (lineFirstInline l - minX)
      n = truncate (offset / max charW 1)
  in T.replicate n " "

joinCodeLines :: [Line] -> T.Text
joinCodeLines ls =
  let minX = minimum (map lineFirstInline ls)
      charW = minimum (map (max 1 . (* 0.55) . lineSize) ls)
  in T.intercalate "\n"
       [ codeIndentSpaces minX charW l `T.append` T.strip (lineText l)
       | l <- ls
       ]

groupParagraphs :: Int -> [Rect] -> (Double, Double) -> [Line] -> [[Line]]
groupParagraphs wmode graphics bounds lines =
  go [] (filter (not . T.null . T.strip . lineText) (sortLinesByReadingOrder lines))
  where
    go _ [] = []
    go pageGaps (l:ls) =
      let (para, rest, pageGaps') = takeParagraph pageGaps l ls
      in para : go pageGaps' rest

    takeParagraph pageGaps first rest =
      let (paraLines, rest', pageGaps') =
            spanLines pageGaps first rest [first] (lineInlineStart first)
      in (paraLines, rest', pageGaps')

    spanLines pageGaps prev rest acc minInline =
      case rest of
        [] -> (reverse acc, [], pageGaps)
        (l:ls) ->
          if paragraphBreak wmode graphics bounds prev l pageGaps minInline
          then (reverse acc, l:ls, pageGaps)
          else let g = baselineGap wmode prev l
                   pageGaps' = if g > 0 then pageGaps ++ [g] else pageGaps
                   minInline' = min minInline (lineInlineStart l)
               in spanLines pageGaps' l ls (l:acc) minInline'

paragraphBreak :: Int -> [Rect] -> (Double, Double) -> Line -> Line -> [Double] -> Double -> Bool
paragraphBreak wmode graphics pageBounds prev cur gaps paraMinInline =
  let gap = baselineGap wmode prev cur
      typical = typicalLeading gaps (lineSize cur)
      gapBreak = abs gap > 1.6 * typical
  in negativeAdvance wmode prev cur
  || listMarkerStart cur
  || afterListHeadingBreak wmode prev cur gaps
  || sameHangListItemBreak wmode prev cur gaps
  || codeBlockBreak prev cur
  || (gapBreak && not (cjkWrapContinuation prev cur))
  || indentBreak paraMinInline cur
  || (graphicBreak wmode graphics pageBounds prev cur
      && not (cjkWrapContinuation prev cur))

cjkWrapContinuation :: Line -> Line -> Bool
cjkWrapContinuation prev cur =
  case (lastChar (lineText prev), firstChar (lineText cur)) of
    (Just a, Just b) ->
      isCJK a && isCJK b && not (endsWithTerminal (lineText prev))
    _ -> False

fixDingbatBulletLine :: Line -> Line
fixDingbatBulletLine l = l {lineText = fixDingbatBullet (lineText l)}

fixDingbatBullet :: T.Text -> T.Text
fixDingbatBullet t =
  let open = T.singleton '\x300c'
      t1 = fixPrefix t
  in T.replace (T.pack " r" `T.append` open) (T.pack " \8226" `T.append` open) t1
  where
    fixPrefix t =
      case T.uncons t of
        Just ('r', rest) ->
          case T.uncons rest of
            Just ('\x300c', _) -> T.cons '\x2022' rest
            Just (' ', rest') ->
              case T.uncons rest' of
                Just (c, _) | not (isLowerLatin c) -> T.cons '\x2022' (T.cons ' ' rest')
                _ -> t
            Nothing -> T.singleton '\x2022'
            _ -> t
        _ -> t
    isLowerLatin c = c >= 'a' && c <= 'z'

baselineGap :: Int -> Line -> Line -> Double
baselineGap wmode prev cur = lineBaseline prev - lineBaseline cur

negativeAdvance :: Int -> Line -> Line -> Bool
negativeAdvance wmode prev cur = baselineGap wmode prev cur < 0

typicalLeading :: [Double] -> Double -> Double
typicalLeading gaps lineSize =
  case sort (filter (> 0) gaps) of
    [] -> 1.2 * lineSize
    [_] -> 1.2 * lineSize
    gs ->
      let mid = length gs `div` 2
      in if odd (length gs)
         then gs !! mid
         else (gs !! (mid - 1) + gs !! mid) / 2

indentBreak :: Double -> Line -> Bool
indentBreak paraMinInline cur =
  lineFirstInline cur - paraMinInline >= 0.85 * lineSize cur

indentPageBreak :: Double -> Line -> Bool
indentPageBreak pageMinInline cur =
  lineFirstInline cur - pageMinInline >= 0.85 * lineSize cur

graphicBreak :: Int -> [Rect] -> (Double, Double) -> Line -> Line -> Bool
graphicBreak wmode graphics pageBounds prev cur =
  any (separatesLines wmode pageW pageH prev cur) (candidatesBetween wmode prev cur pageBounds graphics)
  where
    (pageW, pageH) = pageBounds

candidatesBetween :: Int -> Line -> Line -> (Double, Double) -> [Rect] -> [Rect]
candidatesBetween wmode prev cur (pageW, pageH) graphics =
  let tol = 0.2 * lineSize cur
      lo = min (lineBaseline prev) (lineBaseline cur) - tol
      hi = max (lineBaseline prev) (lineBaseline cur) + tol
  in filter (graphicCandidate wmode pageW pageH lo hi) graphics

graphicCandidate :: Int -> Double -> Double -> Double -> Double -> Rect -> Bool
graphicCandidate wmode pageW pageH lo hi r =
  let bigEnough = rectWidth r > 0.8 * pageW && rectHeight r > 0.8 * pageH
      tiny = rectWidth r < 0.5 && rectHeight r < 0.5
      rLo = if wmode == 1 then min (rectX0 r) (rectX1 r) else min (rectY0 r) (rectY1 r)
      rHi = if wmode == 1 then max (rectX0 r) (rectX1 r) else max (rectY0 r) (rectY1 r)
  in not bigEnough && not tiny && rLo <= hi && rHi >= lo

separatesLines :: Int -> Double -> Double -> Line -> Line -> Rect -> Bool
separatesLines wmode _pageW _pageH prev cur r =
  inlineOverlap wmode prev cur r

rectWidth :: Rect -> Double
rectWidth r = abs (rectX1 r - rectX0 r)

rectHeight :: Rect -> Double
rectHeight r = abs (rectY1 r - rectY0 r)

inlineOverlap :: Int -> Line -> Line -> Rect -> Bool
inlineOverlap wmode prev cur r =
  let unionLo = min (lineInlineStart prev) (lineInlineStart cur)
      unionHi = max (lineInlineEnd prev) (lineInlineEnd cur)
      unionLen = max 0 (unionHi - unionLo)
      (rLo, rHi) = rectInlineRange wmode r
      overlap = max 0 (min unionHi rHi - max unionLo rLo)
  in unionLen <= 0 || overlap / unionLen >= 0.2

rectInlineRange :: Int -> Rect -> (Double, Double)
rectInlineRange wmode r =
  if wmode == 1
  then (min (rectY0 r) (rectY1 r), max (rectY0 r) (rectY1 r))
  else (min (rectX0 r) (rectX1 r), max (rectX0 r) (rectX1 r))

joinParaLines :: [Line] -> T.Text
joinParaLines [] = T.empty
joinParaLines ls
  | all isCodeLine ls = joinCodeLines ls
  | otherwise =
      T.strip $ foldl1 mergeText (map (T.strip . lineText) ls)
  where
    mergeText a b =
      let a' = T.stripEnd a
          b' = T.stripStart b
      in a' `T.append` paraJoinSep a' b' `T.append` b'

terminalChars :: String
terminalChars = "。.!?!?…"

closingChars :: String
closingChars = "」』))]】〉》\"'"

endsWithTerminal :: T.Text -> Bool
endsWithTerminal t = endsWithTerminal' (T.strip t)
  where
    endsWithTerminal' s
      | T.null s = False
      | otherwise =
          case T.unsnoc s of
            Nothing -> False
            Just (init, c)
              | c `elem` closingChars -> endsWithTerminal' init
              | c `elem` terminalChars -> True
              | otherwise -> False