diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
 # Changelog
 
+## 0.4.7.0 (2026-07-06)
+
+### Added
+
+- `PDF.StreamLex` — shared content-stream lexing (PDF number normalization, hex pairs, UTF-16BE / SJIS / JIS code splitting) used by both legacy and geometry pipelines.
+- Golden tests for the geometry extraction path (`data/fixtures/expected-geom/`).
+- Unit tests for RC4 key-stream generation (`Encrypt.rc4KeyStream`) and TUI viewport height parsing/clamping (`--height N` / `N%`).
+- CLI `--height` for the implicit viewer (`hpdft FILE`) and legacy flat invocation; accepts row count or terminal percentage (e.g. `50%`, `100%`).
+
+### Changed
+
+- Page-tree traversal unified on `Page.pageRefsFromRoot` (legacy `walkdown`, CLI refs/grep/show-page).
+- Legacy page content parse failures emit `PageContentFailed` warnings instead of silent empty pages; grep reports geometry failures on stderr.
+- `DocumentStructure` object-stream header/value parsing returns `PdfResult`; `Encrypt.hexToBytes` no longer throws on malformed hex.
+- Executable sources moved under `app/` (`Cli.*` modules for text/view/grep, `TuiPreview`, `TuiScroll`); library `-O2` aligned with prior executable optimization.
+- Removed ignored `hpdft diff --legacy` flag; fixed legacy help typo for `-p`.
+
+### Breaking
+
+- `PDF.ContentStream` is no longer an exposed module (internal to the library). Use `PDF.StreamLex` for shared lexing helpers.
+- `PDF.Interpret` no longer re-exports `normalizePdfNumber`, `parsePdfNumber`, or `unicodeBytesToCodes`.
+
 ## 0.4.6.4 (2026-07-06)
 
 ### Fixed
diff --git a/TuiPreview.hs b/TuiPreview.hs
deleted file mode 100644
--- a/TuiPreview.hs
+++ /dev/null
@@ -1,382 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-
-module TuiPreview (runTuiPreview) where
-
-import PDF.Document (Document)
-import PDF.Text (pdfToTextStreamDoc)
-import TuiScroll
-  ( ScrollState(..)
-  , initialScrollState
-  , clampScrollTop
-  , scrollBy
-  , scrollToTop
-  , scrollToEnd
-  , scrollHalfPageDown
-  , scrollHalfPageUp
-  , searchForwardFrom
-  , searchBackwardFrom
-  , visibleLineRange
-  , statusLineNumber
-  , stringDisplayWidth
-  , clipToDisplayWidth
-  , padToDisplayWidth
-  )
-
-import Control.Concurrent (forkIO, killThread, threadDelay)
-import Control.Exception (AsyncException(UserInterrupt), bracket, catch, finally, throwIO)
-import Control.Monad (when)
-import Data.Array ((!))
-import Data.Char (isPrint)
-import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
-import Data.Text (Text)
-import qualified Data.Text as T
-import qualified Data.Text.Encoding as TE
-import qualified Data.ByteString.Lazy as BSL
-import System.Console.Terminal.Size (Window(..), hSize)
-import System.IO
-  ( BufferMode(..)
-  , Handle
-  , hFlush
-  , hGetBuffering
-  , hGetChar
-  , hGetEcho
-  , hPutStr
-  , hReady
-  , hSetBuffering
-  , hSetEcho
-  , stderr
-  , stdin
-  , stdout
-  )
-import Text.Regex.Base.RegexLike (makeRegexM, matchAll, matchTest)
-import Text.Regex.TDFA (Regex)
-
-runTuiPreview :: FilePath -> Document -> IO ()
-runTuiPreview path doc =
-  let run term =
-        uiLoop term path doc `catch` \e ->
-          case e of
-            UserInterrupt -> return ()
-            _ -> throwIO e
-  in bracket acquireTerminal releaseTerminal run
-
-data Terminal = Terminal
-  { termStdout      :: Handle
-  , termStderr      :: Handle
-  , termStdin       :: Handle
-  , termOldEcho     :: Bool
-  , termOldBuffer   :: BufferMode
-  , termRows        :: Int
-  , termCols        :: Int
-  , termViewportH   :: Int
-  , termTextRows    :: Int
-  , termViewportTop :: Int
-  }
-
-data Key
-  = KChar Char
-  | KUp
-  | KDown
-  | KPageUp
-  | KPageDown
-  | KEnter
-  | KBackspace
-  | KEsc
-  | KOther
-
--- | Status-line mode: normal view, or entering a search pattern after @/@.
-data UiMode = ModeView | ModeInput String
-
-acquireTerminal :: IO Terminal
-acquireTerminal = do
-  oldEcho <- hGetEcho stdin
-  oldBuf <- hGetBuffering stdin
-  hSetEcho stdin False
-  hSetBuffering stdin NoBuffering
-  (rows, cols, vh, textRows, vTop) <- queryGeometry stdout
-  -- Hide the cursor and disable autowrap: a wrap on the bottom row would
-  -- scroll the whole screen and drag the viewport out of place.
-  hPutStr stdout "\ESC[?25l\ESC[?7l"
-  hFlush stdout
-  return Terminal
-    { termStdout = stdout
-    , termStderr = stderr
-    , termStdin = stdin
-    , termOldEcho = oldEcho
-    , termOldBuffer = oldBuf
-    , termRows = rows
-    , termCols = cols
-    , termViewportH = vh
-    , termTextRows = textRows
-    , termViewportTop = vTop
-    }
-
-releaseTerminal :: Terminal -> IO ()
-releaseTerminal term = do
-  clearViewport term
-  hPutStr (termStdout term) "\ESC[?25h\ESC[?7h\ESC[0m"
-  hPutStr (termStdout term) $ "\ESC[" ++ show (termRows term + 1) ++ ";1H"
-  hFlush (termStdout term)
-  hSetEcho (termStdin term) (termOldEcho term)
-  hSetBuffering (termStdin term) (termOldBuffer term)
-
--- Viewport layout: top bar, text rows, status bar (bottom).
-queryGeometry :: Handle -> IO (Int, Int, Int, Int, Int)
-queryGeometry h = do
-  mw <- hSize h
-  let reported f dflt = case mw of
-        Just w | fromIntegral (f w) > 0 -> fromIntegral (f w)
-        _ -> dflt
-      rows = reported height 24
-      cols = reported width 80
-      vh = max 6 (rows `div` 2)
-      textRows = max 1 (vh - 2)
-      vTop = rows - vh + 1
-  return (rows, cols, vh, textRows, vTop)
-
-readKey :: Handle -> IO Key
-readKey h = do
-  c <- hGetChar h
-  case c of
-    '\ESC' -> do
-      pending <- hReady h
-      if not pending
-        then return KEsc
-        else do
-          c1 <- hGetChar h
-          if c1 /= '['
-            then return KEsc
-            else do
-              c2 <- hGetChar h
-              case c2 of
-                'A' -> return KUp
-                'B' -> return KDown
-                '5' -> swallowTilde >> return KPageUp
-                '6' -> swallowTilde >> return KPageDown
-                _   -> return KOther
-    '\r'   -> return KEnter
-    '\n'   -> return KEnter
-    '\DEL' -> return KBackspace
-    '\b'   -> return KBackspace
-    _      -> return (KChar c)
-  where
-    swallowTilde = do
-      pending <- hReady h
-      when pending $ hGetChar h >> return ()
-
-uiLoop :: Terminal -> FilePath -> Document -> IO ()
-uiLoop term path doc = do
-  linesRef <- newIORef ([] :: [Text])
-  pagesLoadedRef <- newIORef (0 :: Int)
-  totalPagesRef <- newIORef (0 :: Int)
-  doneRef <- newIORef False
-  scrollRef <- newIORef (initialScrollState (termTextRows term))
-  redrawRef <- newIORef True
-  modeRef <- newIORef ModeView
-  searchRef <- newIORef (Nothing :: Maybe (String, Regex))
-  matchRef <- newIORef (Nothing :: Maybe Int)
-  msgRef <- newIORef (Nothing :: Maybe String)
-
-  let producer = do
-        _ <- pdfToTextStreamDoc doc $ \pg total bs -> do
-          writeIORef totalPagesRef total
-          modifyIORef' linesRef $ appendChunk (decodeLenient bs)
-          writeIORef pagesLoadedRef pg
-          ls <- readIORef linesRef
-          modifyIORef' scrollRef (\st -> st {scrollTotalLines = length ls})
-          writeIORef redrawRef True
-        writeIORef doneRef True
-        writeIORef redrawRef True
-
-  let syncTotal = do
-        ls <- readIORef linesRef
-        modifyIORef' scrollRef (\st -> st {scrollTotalLines = length ls})
-        return ls
-
-      -- Jump forward (or backward) to the next line matching the active pattern.
-      searchStep forward = do
-        msearch <- readIORef searchRef
-        case msearch of
-          Nothing -> writeIORef msgRef (Just "No previous search")
-          Just (_, re) -> do
-            ls <- syncTotal
-            st <- readIORef scrollRef
-            cur <- readIORef matchRef
-            let p = matchTest re . T.unpack
-                top = scrollTopLine st
-                found
-                  | forward = searchForwardFrom p (maybe (top + 1) (+ 1) cur) ls
-                  | otherwise = searchBackwardFrom p (maybe (top - 1) (subtract 1) cur) ls
-            case found of
-              Nothing -> writeIORef msgRef (Just "Pattern not found")
-              Just i -> do
-                writeIORef matchRef (Just i)
-                modifyIORef' scrollRef (clampScrollTop i)
-
-      startSearch q
-        | null q = do
-            msearch <- readIORef searchRef
-            case msearch of
-              Just _ -> searchStep True
-              Nothing -> return ()
-        | otherwise =
-            case makeRegexM q :: Maybe Regex of
-              Nothing -> writeIORef msgRef (Just "Invalid pattern")
-              Just re -> do
-                writeIORef searchRef (Just (q, re))
-                writeIORef matchRef Nothing
-                searchStep True
-
-      handleViewKey key = case key of
-        KChar 'q' -> return True
-        KChar '\ETX' -> return True
-        KChar '/' -> writeIORef modeRef (ModeInput "") >> return False
-        KChar 'n' -> searchStep True >> return False
-        KChar 'N' -> searchStep False >> return False
-        _ -> do
-          _ <- syncTotal
-          modifyIORef' scrollRef (moveKey (termTextRows term) key)
-          return False
-
-      handleInputKey q key = do
-        case key of
-          KEsc -> writeIORef modeRef ModeView
-          KEnter -> do
-            writeIORef modeRef ModeView
-            startSearch q
-          KBackspace ->
-            writeIORef modeRef $
-              if null q then ModeView else ModeInput (init q)
-          KChar c
-            | c == '\ETX' -> writeIORef modeRef ModeView
-            | isPrint c -> writeIORef modeRef (ModeInput (q ++ [c]))
-          _ -> return ()
-        return False
-
-  let loop = do
-        needRedraw <- readIORef redrawRef
-        when needRedraw $ do
-          writeIORef redrawRef False
-          ls <- readIORef linesRef
-          pg <- readIORef pagesLoadedRef
-          total <- readIORef totalPagesRef
-          done <- readIORef doneRef
-          scroll <- readIORef scrollRef
-          mode <- readIORef modeRef
-          msg <- readIORef msgRef
-          msearch <- readIORef searchRef
-          drawFrame term path scroll ls pg total done mode msg (fmap snd msearch)
-        ready <- hReady (termStdin term)
-        if ready
-          then do
-            key <- readKey (termStdin term)
-            writeIORef msgRef Nothing
-            mode <- readIORef modeRef
-            quit <- case mode of
-              ModeView -> handleViewKey key
-              ModeInput q -> handleInputKey q key
-            writeIORef redrawRef True
-            if quit then return () else loop
-          else do
-            threadDelay 50000
-            loop
-
-  tid <- forkIO producer
-  loop `finally` killThread tid
-
-moveKey :: Int -> Key -> ScrollState -> ScrollState
-moveKey textRows key st = case key of
-  KChar 'j' -> scrollBy 1 st
-  KChar ' ' -> scrollBy 1 st
-  KChar 'k' -> scrollBy (-1) st
-  KDown     -> scrollBy 1 st
-  KUp       -> scrollBy (-1) st
-  KPageDown -> scrollBy textRows st
-  KPageUp   -> scrollBy (-textRows) st
-  KChar 'g' -> scrollToTop st
-  KChar 'G' -> scrollToEnd st
-  KChar 'd' -> scrollHalfPageDown st
-  KChar 'u' -> scrollHalfPageUp st
-  _         -> st
-
--- | Line buffer invariant: the last element is the still-open line
--- (empty when the previous chunk ended with a newline).
-appendChunk :: Text -> [Text] -> [Text]
-appendChunk chunk [] = T.split (== '\n') chunk
-appendChunk chunk ls =
-  case T.split (== '\n') chunk of
-    (c : cs) -> init ls ++ (last ls <> c) : cs
-    [] -> ls
-
-decodeLenient :: BSL.ByteString -> Text
-decodeLenient bs =
-  TE.decodeUtf8With (\_ _ -> Just '\xfffd') (BSL.toStrict bs)
-
-drawFrame ::
-  Terminal -> FilePath -> ScrollState -> [Text] ->
-  Int -> Int -> Bool -> UiMode -> Maybe String -> Maybe Regex -> IO ()
-drawFrame term path scroll ls pg total done mode msg mre = do
-  let (start, end) = visibleLineRange scroll
-      visible = take (end - start) (drop start ls)
-      status = case mode of
-        ModeInput q -> "/" ++ q
-        ModeView -> case msg of
-          Just m -> " " ++ m
-          Nothing ->
-            " page "
-              ++ show (if total > 0 then pg else 0)
-              ++ "/"
-              ++ show total
-              ++ (if done then "" else "...")
-              ++ " | line "
-              ++ show (statusLineNumber scroll)
-              ++ "/"
-              ++ show (max (length ls) 1)
-              ++ " | j/k \x2193/\x2191 d/u g/G /:search n/N q:quit"
-  drawBarRow term (termViewportTop term) (" " ++ path)
-  mapM_ (drawTextRow term mre) (zip [0 ..] visible)
-  let filled = length visible
-      statusRow = termViewportTop term + termViewportH term - 1
-  mapM_ (\i -> drawTextRow term Nothing (i, "")) [filled .. termTextRows term - 1]
-  drawBarRow term statusRow status
-  -- During search input, park the visible cursor after the query so IME
-  -- composition windows anchor to the right spot.
-  case mode of
-    ModeInput _ -> do
-      let col = min (termCols term) (stringDisplayWidth status + 1)
-      hPutStr (termStdout term) $
-        "\ESC[" ++ show statusRow ++ ";" ++ show col ++ "H\ESC[?25h"
-    ModeView ->
-      hPutStr (termStdout term) "\ESC[?25l"
-  hFlush (termStdout term)
-
-drawTextRow :: Terminal -> Maybe Regex -> (Int, Text) -> IO ()
-drawTextRow term mre (i, txt) = do
-  let row = termViewportTop term + 1 + i
-      clipped = clipToDisplayWidth (termCols term) (T.unpack txt)
-      rendered = maybe clipped (`highlightMatches` clipped) mre
-  hPutStr (termStdout term) $ "\ESC[" ++ show row ++ ";1H\ESC[K" ++ rendered
-
--- | Wrap every regex match on the (already clipped) line in reverse video.
-highlightMatches :: Regex -> String -> String
-highlightMatches re s = go 0 s spans
-  where
-    spans = [(off, len) | ma <- matchAll re s, let (off, len) = ma ! 0, len > 0]
-    go _ rest [] = rest
-    go pos rest ((off, len) : more) =
-      let (before, rest') = splitAt (off - pos) rest
-          (hit, rest'') = splitAt len rest'
-       in before ++ "\ESC[7m" ++ hit ++ "\ESC[0m" ++ go (off + len) rest'' more
-
--- | Full-width reverse-video bar (used for the top border and the status line).
-drawBarRow :: Terminal -> Int -> String -> IO ()
-drawBarRow term row content = do
-  let padded = padToDisplayWidth (termCols term) content
-  hPutStr (termStdout term) $
-    "\ESC[" ++ show row ++ ";1H\ESC[7m" ++ padded ++ "\ESC[0m"
-
-clearViewport :: Terminal -> IO ()
-clearViewport term =
-  mapM_ clearRow [termViewportTop term .. termViewportTop term + termViewportH term - 1]
-  where
-    clearRow r = hPutStr (termStdout term) $ "\ESC[" ++ show r ++ ";1H\ESC[K"
diff --git a/TuiScroll.hs b/TuiScroll.hs
deleted file mode 100644
--- a/TuiScroll.hs
+++ /dev/null
@@ -1,118 +0,0 @@
-module TuiScroll
-  ( ScrollState(..)
-  , initialScrollState
-  , clampScrollTop
-  , scrollBy
-  , scrollToTop
-  , scrollToEnd
-  , scrollHalfPageDown
-  , scrollHalfPageUp
-  , searchForwardFrom
-  , searchBackwardFrom
-  , visibleLineRange
-  , statusLineNumber
-  , charDisplayWidth
-  , stringDisplayWidth
-  , clipToDisplayWidth
-  , padToDisplayWidth
-  ) where
-
-import Data.Char (ord)
-import Data.List (findIndex)
-
-data ScrollState = ScrollState
-  { scrollTopLine  :: !Int
-  , scrollTotalLines :: !Int
-  , scrollTextRows :: !Int
-  } deriving (Eq, Show)
-
-initialScrollState :: Int -> ScrollState
-initialScrollState textRows =
-  ScrollState {scrollTopLine = 0, scrollTotalLines = 0, scrollTextRows = textRows}
-
-maxScrollTop :: ScrollState -> Int
-maxScrollTop st =
-  max 0 (scrollTotalLines st - scrollTextRows st)
-
-clampScrollTop :: Int -> ScrollState -> ScrollState
-clampScrollTop n st = st {scrollTopLine = clamp 0 (maxScrollTop st) n}
-  where
-    clamp lo hi x = max lo (min hi x)
-
-scrollBy :: Int -> ScrollState -> ScrollState
-scrollBy delta st =
-  clampScrollTop (scrollTopLine st + delta) st
-
-scrollToTop :: ScrollState -> ScrollState
-scrollToTop st = st {scrollTopLine = 0}
-
-scrollToEnd :: ScrollState -> ScrollState
-scrollToEnd st = clampScrollTop maxBound st
-
-scrollHalfPageDown :: ScrollState -> ScrollState
-scrollHalfPageDown st =
-  scrollBy (max 1 (scrollTextRows st `div` 2)) st
-
-scrollHalfPageUp :: ScrollState -> ScrollState
-scrollHalfPageUp st =
-  scrollBy (-max 1 (scrollTextRows st `div` 2)) st
-
--- | Index of the first line at or after @start@ satisfying the predicate.
-searchForwardFrom :: (a -> Bool) -> Int -> [a] -> Maybe Int
-searchForwardFrom p start ls =
-  let from = max 0 start
-   in (+ from) <$> findIndex p (drop from ls)
-
--- | Index of the last line at or before @start@ satisfying the predicate.
-searchBackwardFrom :: (a -> Bool) -> Int -> [a] -> Maybe Int
-searchBackwardFrom p start ls =
-  let upto = min (length ls - 1) start
-      hits = [i | (i, l) <- zip [0 ..] (take (upto + 1) ls), p l]
-   in if upto < 0 || null hits then Nothing else Just (last hits)
-
-visibleLineRange :: ScrollState -> (Int, Int)
-visibleLineRange st =
-  let top = scrollTopLine st
-      end = min (scrollTotalLines st) (top + scrollTextRows st)
-   in (top, end)
-
-statusLineNumber :: ScrollState -> Int
-statusLineNumber st =
-  if scrollTotalLines st == 0
-    then 0
-    else scrollTopLine st + 1
-
--- | Terminal display width: East Asian wide/fullwidth characters occupy
--- two columns. Covers the common CJK blocks; combining marks are not handled.
-charDisplayWidth :: Char -> Int
-charDisplayWidth c
-  | w >= 0x1100 && w <= 0x115F = 2   -- Hangul Jamo
-  | w >= 0x2E80 && w <= 0xA4CF = 2   -- CJK radicals .. Yi
-  | w >= 0xAC00 && w <= 0xD7A3 = 2   -- Hangul syllables
-  | w >= 0xF900 && w <= 0xFAFF = 2   -- CJK compatibility ideographs
-  | w >= 0xFE30 && w <= 0xFE4F = 2   -- CJK compatibility forms
-  | w >= 0xFF00 && w <= 0xFF60 = 2   -- fullwidth forms
-  | w >= 0xFFE0 && w <= 0xFFE6 = 2
-  | w >= 0x20000 && w <= 0x3FFFD = 2 -- CJK extensions
-  | otherwise = 1
-  where w = ord c
-
-stringDisplayWidth :: String -> Int
-stringDisplayWidth = sum . map charDisplayWidth
-
--- | Longest prefix that fits in @width@ display columns.
-clipToDisplayWidth :: Int -> String -> String
-clipToDisplayWidth width = go width
-  where
-    go _ [] = []
-    go left (c : cs)
-      | cw <= left = c : go (left - cw) cs
-      | otherwise = []
-      where cw = charDisplayWidth c
-
--- | Clip and right-pad with spaces to exactly @width@ display columns
--- (may fall one column short when a wide character straddles the edge).
-padToDisplayWidth :: Int -> String -> String
-padToDisplayWidth width s =
-  let clipped = clipToDisplayWidth width s
-   in clipped ++ replicate (width - stringDisplayWidth clipped) ' '
diff --git a/app/Cli/Common.hs b/app/Cli/Common.hs
new file mode 100644
--- /dev/null
+++ b/app/Cli/Common.hs
@@ -0,0 +1,46 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Cli.Common
+  ( maybePassword
+  , withFile
+  , runOrDie
+  , printWarnings
+  ) where
+
+import PDF.Error (PdfError(..), PdfResult, PdfWarning(..), renderPdfError, renderPdfWarning)
+
+import System.Exit (exitWith, ExitCode(..))
+import System.IO (hPutStrLn, stderr)
+import System.IO.Error (isDoesNotExistError)
+import Control.Exception (catch, IOException, ioError)
+
+maybePassword :: String -> Maybe String
+maybePassword pw = if null pw then Nothing else Just pw
+
+withFile :: FilePath -> IO () -> IO ()
+withFile fp action =
+  action `catch` \e -> do
+    if isDoesNotExistError (e :: IOException)
+      then do
+        hPutStrLn stderr ("hpdft: " ++ fp ++ ": does not exist")
+        exitWith (ExitFailure 1)
+      else ioError e
+
+describeError :: PdfError -> String
+describeError err =
+  case err of
+    DecryptionError msg ->
+      "cannot decrypt: " ++ msg ++ ". Use -P to supply a password."
+    _ -> renderPdfError err
+
+runOrDie :: IO (PdfResult a) -> IO a
+runOrDie action = do
+  result <- action
+  case result of
+    Right a -> return a
+    Left err -> do
+      hPutStrLn stderr ("hpdft: " ++ describeError err)
+      exitWith (ExitFailure 1)
+
+printWarnings :: [PdfWarning] -> IO ()
+printWarnings = mapM_ (hPutStrLn stderr . ("hpdft: warning: " ++) . renderPdfWarning)
diff --git a/app/Cli/Grep.hs b/app/Cli/Grep.hs
new file mode 100644
--- /dev/null
+++ b/app/Cli/Grep.hs
@@ -0,0 +1,57 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Cli.Grep (grepPDF) where
+
+import Cli.Common (runOrDie, withFile)
+
+import PDF.Document (Document(..), docRootRef, openDocument)
+import PDF.Error (renderPdfError)
+import PDF.Layout (LayoutOptions, defaultLayoutOptions)
+import PDF.Page (pageRefsFromRoot)
+import PDF.Text (pageTextGeomWith)
+
+import System.IO (hPutStrLn, putStrLn, stderr)
+
+import Data.ByteString.UTF8 (ByteString)
+import qualified Data.ByteString.Lazy.Char8 as BSL
+import Data.Text.Lazy as TL (unpack)
+import Data.Text.Lazy.Encoding as TL
+import Control.Monad (when)
+
+import Text.Regex.Base.RegexLike (makeRegex)
+import Text.Regex.TDFA.String (regexec)
+
+grepPDF :: LayoutOptions -> FilePath -> Maybe String -> String -> IO ()
+grepPDF lopts filename mpw re =
+  withFile filename $ do
+    doc <- runOrDie (openDocument filename mpw)
+    root <- runOrDie (return (docRootRef doc))
+    let objs = docObjs doc
+        refs = pageRefsFromRoot root objs
+    mapM_
+      (\(ref, pagenm) -> grepByPage doc lopts pagenm re ref)
+      (zip refs [1 ..])
+
+grepByPage :: Document -> LayoutOptions -> Int -> String -> Int -> IO ()
+grepByPage doc lopts pagenm re ref =
+  case pageTextGeomWith lopts doc ref of
+    Right txt -> do
+      let matched = filter (not . null) $ map (grepByLine re) (BSL.lines txt)
+      when (not (null matched)) (showResult pagenm matched)
+    Left err ->
+      hPutStrLn stderr ("hpdft: page " ++ show pagenm ++ ": " ++ renderPdfError err)
+
+showResult :: Int -> [String] -> IO ()
+showResult p m = do
+  putStrLn $ "At page " <> show p <> "..."
+  mapM_ (putStrLn . (" | " <>)) m
+
+grepByLine :: String -> BSL.ByteString -> String
+grepByLine re txt =
+  case regexec (makeRegex re) (TL.unpack (TL.decodeUtf8 txt)) of
+    Left _  -> ""
+    Right m -> case m of
+      Just (b, m', a, _) -> b <> highlight m' <> a
+      Nothing            -> ""
+  where
+    highlight x = "\ESC[31m" <> x <> "\ESC[0m"
diff --git a/app/Cli/Misc.hs b/app/Cli/Misc.hs
new file mode 100644
--- /dev/null
+++ b/app/Cli/Misc.hs
@@ -0,0 +1,200 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Cli.Misc
+  ( runExtractImages
+  , runExtractForm
+  , runDiff
+  , showRefs
+  , showContent
+  , showTitle
+  , showInfo
+  , showOutlines
+  , showTrailer
+  ) where
+
+import Cli.Common (maybePassword, runOrDie, withFile)
+import Cli.Parser (DiffOpt(..), FormOpt(..), ImagesOpt(..))
+
+import PDF.Definition (Obj(..), ppObj, ppDictEntries)
+import PDF.Document (Document(..), docInfoDict, docRootRef, docTrailer, openDocument)
+import PDF.DocumentStructure
+import PDF.Diff (TextChange(..), compareDocuments)
+import PDF.FormExtract (extractFormToFile, pageFormNames)
+import PDF.Image (extractPageImagesToDir)
+import PDF.Layout (LayoutOptions(..), defaultLayoutOptions)
+import PDF.Outlines (getOutlines)
+import PDF.Page (pageRefsFromRoot)
+import PDF.PDFIO (getObjectByRef, getStream)
+
+import System.Exit (exitWith, ExitCode(..))
+import System.IO (hPutStrLn, putStrLn, stderr)
+
+import qualified Data.ByteString.Lazy.Char8 as BSL
+import qualified Data.Text as T
+import Data.List (find, intercalate)
+
+runExtractImages :: ImagesOpt -> IO ()
+runExtractImages ImagesOpt{ioPage=pg, ioOut=out, ioPassword=pw, ioFile=fn} =
+  withFile fn $
+  if pg < 1
+    then do
+      hPutStrLn stderr "hpdft: image requires -p PAGE (1-based)"
+      exitWith (ExitFailure 1)
+    else do
+      doc <- runOrDie (openDocument fn (maybePassword pw))
+      paths <- runOrDie (extractPageImagesToDir doc pg out)
+      if null paths
+        then hPutStrLn stderr ("hpdft: no image XObjects on page " ++ show pg)
+        else mapM_ putStrLn paths
+
+runExtractForm :: FormOpt -> IO ()
+runExtractForm FormOpt{foPage=pg, foName=mn, foOut=out, foPassword=pw, foFile=fn} =
+  withFile fn $
+  if pg < 1
+    then do
+      hPutStrLn stderr "hpdft: form requires -p PAGE (1-based)"
+      exitWith (ExitFailure 1)
+    else do
+      doc <- runOrDie (openDocument fn (maybePassword pw))
+      case mn of
+        Nothing -> do
+          names <- runOrDie (return (pageFormNames doc pg))
+          if null names
+            then hPutStrLn stderr ("hpdft: no Form XObjects on page " ++ show pg)
+            else mapM_ (putStrLn . T.unpack) names
+        Just nm -> do
+          path <- runOrDie (extractFormToFile doc pg (T.pack nm) out)
+          putStrLn path
+
+runDiff :: DiffOpt -> IO ()
+runDiff DiffOpt{doRuby=rb, doJson=json, doPassword=pw, doFileA=fa, doFileB=fb} =
+  withFile fa $
+  withFile fb $
+  let mpw = maybePassword pw
+      lopts = defaultLayoutOptions {optRuby = rb}
+  in do
+    docA <- runOrDie (openDocument fa mpw)
+    docB <- runOrDie (openDocument fb mpw)
+    changes <- runOrDie (return (compareDocuments lopts docA docB))
+    if json
+      then putStrLn (renderDiffJson changes)
+      else mapM_ putStrLn (renderDiffHuman changes)
+
+renderDiffHuman :: [TextChange] -> [String]
+renderDiffHuman = map renderOne
+  where
+    renderOne (PageCountMismatch pa pb) =
+      "page count mismatch: " ++ show pa ++ " vs " ++ show pb
+    renderOne TextChange{changePageA = pa, changePageB = pb,
+                         changeParaA = pxa, changeParaB = pxb,
+                         changeOld = old, changeNew = new} =
+      unlines
+        ( pageLine
+        : paraLine
+        : ("- old: " ++ T.unpack old) : ("+ new: " ++ T.unpack new) : []
+        )
+      where
+        pageLine =
+          case (pa, pb) of
+            (Just a, Just b) | a == b -> "page " ++ show a ++ ":"
+            (Just a, Just b) -> "page " ++ show a ++ " vs " ++ show b ++ ":"
+            (Just a, Nothing) -> "page " ++ show a ++ " (only in first file):"
+            (Nothing, Just b) -> "page " ++ show b ++ " (only in second file):"
+            _ -> "page ?:"
+        paraLine =
+          case (pxa, pxb) of
+            (Just a, Just b) | a == b -> "para " ++ show (a + 1) ++ ":"
+            (Just a, Just b) -> "para " ++ show (a + 1) ++ " vs " ++ show (b + 1) ++ ":"
+            (Just a, Nothing) -> "para " ++ show (a + 1) ++ ":"
+            (Nothing, Just b) -> "para " ++ show (b + 1) ++ ":"
+            _ -> "para ?:"
+
+renderDiffJson :: [TextChange] -> String
+renderDiffJson changes = "[" ++ intercalate "," (map encodeChange changes) ++ "]"
+  where
+    encodeChange (PageCountMismatch pa pb) =
+      "{\"type\":\"pageCountMismatch\",\"pagesA\":" ++ show pa
+        ++ ",\"pagesB\":" ++ show pb ++ "}"
+    encodeChange TextChange{changePageA = pa, changePageB = pb,
+                            changeParaA = pxa, changeParaB = pxb,
+                            changeOld = old, changeNew = new} =
+      "{\"type\":\"textChange\""
+        ++ maybeField "pageA" pa
+        ++ maybeField "pageB" pb
+        ++ maybeField "paraA" pxa
+        ++ maybeField "paraB" pxb
+        ++ ",\"old\":" ++ jsonString old
+        ++ ",\"new\":" ++ jsonString new
+        ++ "}"
+    maybeField _ Nothing = ""
+    maybeField k (Just v) = ",\"" ++ k ++ "\":" ++ show v
+    jsonString t =
+      "\"" ++ concatMap esc (T.unpack t) ++ "\""
+    esc '\\' = "\\\\"
+    esc '"' = "\\\""
+    esc '\n' = "\\n"
+    esc '\r' = "\\r"
+    esc '\t' = "\\t"
+    esc c = [c]
+
+showRefs :: FilePath -> Maybe String -> IO ()
+showRefs filename mpw = do
+  doc <- runOrDie (openDocument filename mpw)
+  root <- runOrDie (return (docRootRef doc))
+  print $ pageRefsFromRoot root (docObjs doc)
+
+showContent :: FilePath -> Maybe String -> Int -> IO ()
+showContent filename mpw ref = do
+  doc <- runOrDie (openDocument filename mpw)
+  let objs = docObjs doc
+      sec = docSecurity doc
+  obj <- runOrDie (getObjectByRef ref objs)
+  if hasStream obj
+    then case findDict obj of
+      Just d | hasSubtype d -> printStreamWithDict sec ref d obj
+      _ -> do
+        strm <- runOrDie (getStream sec ref False obj)
+        BSL.putStrLn strm
+    else do
+      objs' <- runOrDie (getObjectByRef ref objs)
+      putStrLn $ "[" ++ intercalate ", " (map ppObj objs') ++ "]"
+  where
+    hasStream obj = case find isStream obj of
+      Just _ -> True
+      Nothing -> False
+    isStream (PdfStream _) = True
+    isStream _             = False
+    hasSubtype d = case findObjFromDict d "/Subtype" of
+      Just _ -> True
+      Nothing -> False
+    printStreamWithDict sec' ref' d obj = do
+      putStrLn $ ppObj (PdfDict d)
+      strm <- runOrDie (getStream sec' ref' True obj)
+      BSL.putStrLn strm
+
+showTitle :: FilePath -> Maybe String -> IO ()
+showTitle filename mpw = do
+  doc <- runOrDie (openDocument filename mpw)
+  d <- runOrDie (return (docInfoDict doc))
+  let title =
+        case findObjFromDict d "/Title" of
+          Just (PdfText s) -> T.unpack s
+          Just x -> ppObj x
+          Nothing -> "No title anyway"
+  putStrLn title
+
+showInfo :: FilePath -> Maybe String -> IO ()
+showInfo filename mpw = do
+  doc <- runOrDie (openDocument filename mpw)
+  d <- runOrDie (return (docInfoDict doc))
+  putStrLn $ ppObj (PdfDict d)
+
+showOutlines :: FilePath -> Maybe String -> IO ()
+showOutlines filename mpw = do
+  d <- runOrDie (getOutlines filename mpw)
+  putStrLn $ show d
+
+showTrailer :: FilePath -> IO ()
+showTrailer filename = do
+  doc <- runOrDie (openDocument filename Nothing)
+  putStrLn $ ppDictEntries (docTrailer doc)
diff --git a/app/Cli/Parser.hs b/app/Cli/Parser.hs
new file mode 100644
--- /dev/null
+++ b/app/Cli/Parser.hs
@@ -0,0 +1,77 @@
+module Cli.Parser
+  ( Cmd(..)
+  , ExtractOpt(..)
+  , ImagesOpt(..)
+  , FormOpt(..)
+  , DiffOpt(..)
+  , LegacyOpt(..)
+  ) where
+
+data Cmd
+  = CmdText ExtractOpt
+  | CmdView FilePath (Maybe String) (Maybe String)
+  | CmdImage ImagesOpt
+  | CmdForm FormOpt
+  | CmdDiff DiffOpt
+  | CmdInfo FilePath (Maybe String)
+  | CmdTitle FilePath (Maybe String)
+  | CmdToc FilePath (Maybe String)
+  | CmdTrailer FilePath
+  | CmdObject Int FilePath (Maybe String)
+  | CmdRefs FilePath (Maybe String)
+  | CmdGrep String FilePath (Maybe String)
+
+data ExtractOpt = ExtractOpt
+  { eoPage      :: Int
+  , eoGeom      :: Bool
+  , eoTagged    :: Bool
+  , eoLegacy    :: Bool
+  , eoQuiet     :: Bool
+  , eoFootnotes :: Bool
+  , eoRuby      :: Bool
+  , eoPassword  :: String
+  , eoFile      :: FilePath
+  }
+
+data ImagesOpt = ImagesOpt
+  { ioPage     :: Int
+  , ioOut      :: FilePath
+  , ioPassword :: String
+  , ioFile     :: FilePath
+  }
+
+data FormOpt = FormOpt
+  { foPage     :: Int
+  , foName     :: Maybe String
+  , foOut      :: FilePath
+  , foPassword :: String
+  , foFile     :: FilePath
+  }
+
+data DiffOpt = DiffOpt
+  { doGeom      :: Bool
+  , doRuby      :: Bool
+  , doJson      :: Bool
+  , doPassword  :: String
+  , doFileA     :: FilePath
+  , doFileB     :: FilePath
+  }
+
+data LegacyOpt = LegacyOpt
+  { loPage      :: Int
+  , loRef       :: Int
+  , loGrep      :: String
+  , loRefs      :: Bool
+  , loGeom      :: Bool
+  , loTagged    :: Bool
+  , loLegacy    :: Bool
+  , loFootnotes :: Bool
+  , loRuby      :: Bool
+  , loTitle     :: Bool
+  , loInfo      :: Bool
+  , loToc       :: Bool
+  , loTrailer   :: Bool
+  , loHeight    :: String
+  , loPassword  :: String
+  , loFile      :: FilePath
+  }
diff --git a/app/Cli/Text.hs b/app/Cli/Text.hs
new file mode 100644
--- /dev/null
+++ b/app/Cli/Text.hs
@@ -0,0 +1,83 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Cli.Text
+  ( runExtractText
+  , streamLegacyToStdout
+  , showPage
+  ) where
+
+import Cli.Common (maybePassword, printWarnings, runOrDie, withFile)
+import Cli.Parser (ExtractOpt(..))
+
+import PDF.Document (Document(..), docRootRef, openDocument)
+import PDF.Layout (LayoutOptions(..), defaultLayoutOptions)
+import PDF.Page (pageRefsFromRoot)
+import PDF.Text
+  ( pageTextGeomWith
+  , pdfToTextGeomBSWith
+  , pdfToTextStreamDoc
+  , pdfToTextTaggedBSWith
+  )
+
+import System.IO (hFlush, hIsTerminalDevice, hPutStr, hPutStrLn, putStrLn, stderr, stdout)
+
+import qualified Data.ByteString.Lazy.Char8 as BSL
+import Data.IORef (newIORef, readIORef, writeIORef)
+import Control.Monad (when)
+
+runExtractText :: ExtractOpt -> IO ()
+runExtractText ExtractOpt{eoPage=pg, eoGeom=gm, eoTagged=tg, eoLegacy=lg,
+                      eoQuiet=quiet, eoFootnotes=fnn, eoRuby=rb,
+                      eoPassword=pw, eoFile=fn} =
+  withFile fn $
+  let mpw = maybePassword pw
+      lopts = defaultLayoutOptions {optFootnotes = fnn, optRuby = rb}
+  in if pg /= 0
+     then showPage lopts fn mpw pg
+     else case () of
+       _ | lg && not gm && not tg -> do
+             doc <- runOrDie (openDocument fn mpw)
+             streamLegacyToStdout doc quiet
+         | gm && not tg && not lg -> pdfToTextGeom lopts fn mpw
+         | otherwise              -> pdfToTextTagged lopts fn mpw
+
+streamLegacyToStdout :: Document -> Bool -> IO ()
+streamLegacyToStdout doc quiet = do
+  stderrTTY <- hIsTerminalDevice stderr
+  let showProgress pg total =
+        when (not quiet && stderrTTY && total > 0) $
+          hPutStr stderr ("\rhpdft: page " ++ show pg ++ "/" ++ show total ++ "...")
+      clearProgress total =
+        when (not quiet && stderrTTY && total > 0) $
+          hPutStr stderr ("\r\ESC[K")
+  totalRef <- newIORef (0 :: Int)
+  ws <- pdfToTextStreamDoc doc $ \pg total txt -> do
+    writeIORef totalRef total
+    showProgress pg total
+    BSL.putStr txt
+    hFlush stdout
+  total <- readIORef totalRef
+  clearProgress total
+  putStrLn ""
+  printWarnings ws
+
+pdfToTextGeom :: LayoutOptions -> FilePath -> Maybe String -> IO ()
+pdfToTextGeom lopts filename mpw = do
+  txt <- runOrDie (pdfToTextGeomBSWith lopts filename mpw)
+  BSL.putStrLn txt
+
+pdfToTextTagged :: LayoutOptions -> FilePath -> Maybe String -> IO ()
+pdfToTextTagged lopts filename mpw = do
+  txt <- runOrDie (pdfToTextTaggedBSWith lopts filename mpw)
+  BSL.putStrLn txt
+
+showPage :: LayoutOptions -> FilePath -> Maybe String -> Int -> IO ()
+showPage lopts filename mpw page = do
+  doc <- runOrDie (openDocument filename mpw)
+  root <- runOrDie (return (docRootRef doc))
+  let pagetree = pageRefsFromRoot root (docObjs doc)
+  if page >= 1 && length pagetree >= page
+    then do
+      txt <- runOrDie (return (pageTextGeomWith lopts doc (pagetree !! (page - 1))))
+      BSL.putStr txt
+    else putStrLn $ "hpdft: No Page " ++ show page
diff --git a/app/Cli/View.hs b/app/Cli/View.hs
new file mode 100644
--- /dev/null
+++ b/app/Cli/View.hs
@@ -0,0 +1,29 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Cli.View (runViewer) where
+
+import Cli.Common (runOrDie, withFile)
+import Cli.Text (streamLegacyToStdout)
+
+import PDF.Document (openDocument)
+import TuiGeometry (parseHeightSpec)
+import TuiPreview (runTuiPreview)
+
+import System.Exit (exitWith, ExitCode(..))
+import System.IO (hIsTerminalDevice, hPutStrLn, stderr, stdout)
+
+runViewer :: FilePath -> Maybe String -> Maybe String -> IO ()
+runViewer filename mpw mHeightStr = do
+  mHeight <- case mHeightStr of
+    Nothing -> return Nothing
+    Just s ->
+      case parseHeightSpec s of
+        Nothing -> do
+          hPutStrLn stderr "hpdft: invalid --height value (use N or N%)"
+          exitWith (ExitFailure 1)
+        Just spec -> return (Just spec)
+  doc <- runOrDie (openDocument filename mpw)
+  stdoutTTY <- hIsTerminalDevice stdout
+  if stdoutTTY
+    then runTuiPreview filename doc mHeight
+    else streamLegacyToStdout doc False
diff --git a/app/TuiGeometry.hs b/app/TuiGeometry.hs
new file mode 100644
--- /dev/null
+++ b/app/TuiGeometry.hs
@@ -0,0 +1,30 @@
+module TuiGeometry
+  ( HeightSpec(..)
+  , parseHeightSpec
+  , viewportHeight
+  ) where
+
+import Data.List (isSuffixOf)
+
+data HeightSpec = HeightRows Int | HeightPercent Int
+  deriving (Eq, Show)
+
+parseHeightSpec :: String -> Maybe HeightSpec
+parseHeightSpec s
+  | null s = Nothing
+  | "%" `isSuffixOf` s =
+      case reads (take (length s - 1) s) of
+        [(n, "")] | n >= 0 && n <= 100 -> Just (HeightPercent n)
+        _ -> Nothing
+  | otherwise =
+      case reads s of
+        [(n, "")] | n > 0 -> Just (HeightRows n)
+        _ -> Nothing
+
+viewportHeight :: Int -> Maybe HeightSpec -> Int
+viewportHeight termRows mSpec =
+  let raw = case mSpec of
+        Nothing -> termRows `div` 2
+        Just (HeightRows n) -> n
+        Just (HeightPercent p) -> termRows * p `div` 100
+  in max 6 (min termRows raw)
diff --git a/app/TuiPreview.hs b/app/TuiPreview.hs
new file mode 100644
--- /dev/null
+++ b/app/TuiPreview.hs
@@ -0,0 +1,375 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module TuiPreview
+  ( runTuiPreview
+  ) where
+
+import TuiGeometry (HeightSpec, parseHeightSpec, viewportHeight)
+
+import PDF.Document (Document)
+import PDF.Text (pdfToTextStreamDoc)
+import TuiScroll
+  ( ScrollState(..)
+  , initialScrollState
+  , clampScrollTop
+  , scrollBy
+  , scrollToTop
+  , scrollToEnd
+  , scrollHalfPageDown
+  , scrollHalfPageUp
+  , searchForwardFrom
+  , searchBackwardFrom
+  , visibleLineRange
+  , statusLineNumber
+  , stringDisplayWidth
+  , clipToDisplayWidth
+  , padToDisplayWidth
+  )
+
+import Control.Concurrent (forkIO, killThread, threadDelay)
+import Control.Exception (AsyncException(UserInterrupt), bracket, catch, finally, throwIO)
+import Control.Monad (when)
+import Data.Array ((!))
+import Data.Char (isPrint)
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import qualified Data.ByteString.Lazy as BSL
+import System.Console.Terminal.Size (Window(..), hSize)
+import System.IO
+  ( BufferMode(..)
+  , Handle
+  , hFlush
+  , hGetBuffering
+  , hGetChar
+  , hGetEcho
+  , hPutStr
+  , hReady
+  , hSetBuffering
+  , hSetEcho
+  , stderr
+  , stdin
+  , stdout
+  )
+import Text.Regex.Base.RegexLike (makeRegexM, matchAll, matchTest)
+import Text.Regex.TDFA (Regex)
+
+runTuiPreview :: FilePath -> Document -> Maybe HeightSpec -> IO ()
+runTuiPreview path doc mHeight =
+  let run term =
+        uiLoop term path doc `catch` \e ->
+          case e of
+            UserInterrupt -> return ()
+            _ -> throwIO e
+  in bracket (acquireTerminal mHeight) releaseTerminal run
+
+data Terminal = Terminal
+  { termStdout      :: Handle
+  , termStderr      :: Handle
+  , termStdin       :: Handle
+  , termOldEcho     :: Bool
+  , termOldBuffer   :: BufferMode
+  , termRows        :: Int
+  , termCols        :: Int
+  , termViewportH   :: Int
+  , termTextRows    :: Int
+  , termViewportTop :: Int
+  }
+
+data Key
+  = KChar Char
+  | KUp
+  | KDown
+  | KPageUp
+  | KPageDown
+  | KEnter
+  | KBackspace
+  | KEsc
+  | KOther
+
+data UiMode = ModeView | ModeInput String
+
+acquireTerminal :: Maybe HeightSpec -> IO Terminal
+acquireTerminal mHeight = do
+  oldEcho <- hGetEcho stdin
+  oldBuf <- hGetBuffering stdin
+  hSetEcho stdin False
+  hSetBuffering stdin NoBuffering
+  (rows, cols, vh, textRows, vTop) <- queryGeometry stdout mHeight
+  hPutStr stdout "\ESC[?25l\ESC[?7l"
+  hFlush stdout
+  return Terminal
+    { termStdout = stdout
+    , termStderr = stderr
+    , termStdin = stdin
+    , termOldEcho = oldEcho
+    , termOldBuffer = oldBuf
+    , termRows = rows
+    , termCols = cols
+    , termViewportH = vh
+    , termTextRows = textRows
+    , termViewportTop = vTop
+    }
+
+releaseTerminal :: Terminal -> IO ()
+releaseTerminal term = do
+  clearViewport term
+  hPutStr (termStdout term) "\ESC[?25h\ESC[?7h\ESC[0m"
+  hPutStr (termStdout term) $ "\ESC[" ++ show (termRows term + 1) ++ ";1H"
+  hFlush (termStdout term)
+  hSetEcho (termStdin term) (termOldEcho term)
+  hSetBuffering (termStdin term) (termOldBuffer term)
+
+queryGeometry :: Handle -> Maybe HeightSpec -> IO (Int, Int, Int, Int, Int)
+queryGeometry h mHeight = do
+  mw <- hSize h
+  let reported f dflt = case mw of
+        Just w | fromIntegral (f w) > 0 -> fromIntegral (f w)
+        _ -> dflt
+      rows = reported height 24
+      cols = reported width 80
+      vh = viewportHeight rows mHeight
+      textRows = max 1 (vh - 2)
+      vTop = rows - vh + 1
+  return (rows, cols, vh, textRows, vTop)
+
+readKey :: Handle -> IO Key
+readKey h = do
+  c <- hGetChar h
+  case c of
+    '\ESC' -> do
+      pending <- hReady h
+      if not pending
+        then return KEsc
+        else do
+          c1 <- hGetChar h
+          if c1 /= '['
+            then return KEsc
+            else do
+              c2 <- hGetChar h
+              case c2 of
+                'A' -> return KUp
+                'B' -> return KDown
+                '5' -> swallowTilde >> return KPageUp
+                '6' -> swallowTilde >> return KPageDown
+                _   -> return KOther
+    '\r'   -> return KEnter
+    '\n'   -> return KEnter
+    '\DEL' -> return KBackspace
+    '\b'   -> return KBackspace
+    _      -> return (KChar c)
+  where
+    swallowTilde = do
+      pending <- hReady h
+      when pending $ hGetChar h >> return ()
+
+uiLoop :: Terminal -> FilePath -> Document -> IO ()
+uiLoop term path doc = do
+  linesRef <- newIORef ([] :: [Text])
+  pagesLoadedRef <- newIORef (0 :: Int)
+  totalPagesRef <- newIORef (0 :: Int)
+  doneRef <- newIORef False
+  scrollRef <- newIORef (initialScrollState (termTextRows term))
+  redrawRef <- newIORef True
+  modeRef <- newIORef ModeView
+  searchRef <- newIORef (Nothing :: Maybe (String, Regex))
+  matchRef <- newIORef (Nothing :: Maybe Int)
+  msgRef <- newIORef (Nothing :: Maybe String)
+
+  let producer = do
+        _ <- pdfToTextStreamDoc doc $ \pg total bs -> do
+          writeIORef totalPagesRef total
+          modifyIORef' linesRef $ appendChunk (decodeLenient bs)
+          writeIORef pagesLoadedRef pg
+          ls <- readIORef linesRef
+          modifyIORef' scrollRef (\st -> st {scrollTotalLines = length ls})
+          writeIORef redrawRef True
+        writeIORef doneRef True
+        writeIORef redrawRef True
+
+  let syncTotal = do
+        ls <- readIORef linesRef
+        modifyIORef' scrollRef (\st -> st {scrollTotalLines = length ls})
+        return ls
+
+      searchStep forward = do
+        msearch <- readIORef searchRef
+        case msearch of
+          Nothing -> writeIORef msgRef (Just "No previous search")
+          Just (_, re) -> do
+            ls <- syncTotal
+            st <- readIORef scrollRef
+            cur <- readIORef matchRef
+            let p = matchTest re . T.unpack
+                top = scrollTopLine st
+                found
+                  | forward = searchForwardFrom p (maybe (top + 1) (+ 1) cur) ls
+                  | otherwise = searchBackwardFrom p (maybe (top - 1) (subtract 1) cur) ls
+            case found of
+              Nothing -> writeIORef msgRef (Just "Pattern not found")
+              Just i -> do
+                writeIORef matchRef (Just i)
+                modifyIORef' scrollRef (clampScrollTop i)
+
+      startSearch q
+        | null q = do
+            msearch <- readIORef searchRef
+            case msearch of
+              Just _ -> searchStep True
+              Nothing -> return ()
+        | otherwise =
+            case makeRegexM q :: Maybe Regex of
+              Nothing -> writeIORef msgRef (Just "Invalid pattern")
+              Just re -> do
+                writeIORef searchRef (Just (q, re))
+                writeIORef matchRef Nothing
+                searchStep True
+
+      handleViewKey key = case key of
+        KChar 'q' -> return True
+        KChar '\ETX' -> return True
+        KChar '/' -> writeIORef modeRef (ModeInput "") >> return False
+        KChar 'n' -> searchStep True >> return False
+        KChar 'N' -> searchStep False >> return False
+        _ -> do
+          _ <- syncTotal
+          modifyIORef' scrollRef (moveKey (termTextRows term) key)
+          return False
+
+      handleInputKey q key = do
+        case key of
+          KEsc -> writeIORef modeRef ModeView
+          KEnter -> do
+            writeIORef modeRef ModeView
+            startSearch q
+          KBackspace ->
+            writeIORef modeRef $
+              if null q then ModeView else ModeInput (init q)
+          KChar c
+            | c == '\ETX' -> writeIORef modeRef ModeView
+            | isPrint c -> writeIORef modeRef (ModeInput (q ++ [c]))
+          _ -> return ()
+        return False
+
+  let loop = do
+        needRedraw <- readIORef redrawRef
+        when needRedraw $ do
+          writeIORef redrawRef False
+          ls <- readIORef linesRef
+          pg <- readIORef pagesLoadedRef
+          total <- readIORef totalPagesRef
+          done <- readIORef doneRef
+          scroll <- readIORef scrollRef
+          mode <- readIORef modeRef
+          msg <- readIORef msgRef
+          msearch <- readIORef searchRef
+          drawFrame term path scroll ls pg total done mode msg (fmap snd msearch)
+        ready <- hReady (termStdin term)
+        if ready
+          then do
+            key <- readKey (termStdin term)
+            writeIORef msgRef Nothing
+            mode <- readIORef modeRef
+            quit <- case mode of
+              ModeView -> handleViewKey key
+              ModeInput q -> handleInputKey q key
+            writeIORef redrawRef True
+            if quit then return () else loop
+          else do
+            threadDelay 50000
+            loop
+
+  tid <- forkIO producer
+  loop `finally` killThread tid
+
+moveKey :: Int -> Key -> ScrollState -> ScrollState
+moveKey textRows key st = case key of
+  KChar 'j' -> scrollBy 1 st
+  KChar ' ' -> scrollBy 1 st
+  KChar 'k' -> scrollBy (-1) st
+  KDown     -> scrollBy 1 st
+  KUp       -> scrollBy (-1) st
+  KPageDown -> scrollBy textRows st
+  KPageUp   -> scrollBy (-textRows) st
+  KChar 'g' -> scrollToTop st
+  KChar 'G' -> scrollToEnd st
+  KChar 'd' -> scrollHalfPageDown st
+  KChar 'u' -> scrollHalfPageUp st
+  _         -> st
+
+appendChunk :: Text -> [Text] -> [Text]
+appendChunk chunk [] = T.split (== '\n') chunk
+appendChunk chunk ls =
+  case T.split (== '\n') chunk of
+    (c : cs) -> init ls ++ (last ls <> c) : cs
+    [] -> ls
+
+decodeLenient :: BSL.ByteString -> Text
+decodeLenient bs =
+  TE.decodeUtf8With (\_ _ -> Just '\xfffd') (BSL.toStrict bs)
+
+drawFrame ::
+  Terminal -> FilePath -> ScrollState -> [Text] ->
+  Int -> Int -> Bool -> UiMode -> Maybe String -> Maybe Regex -> IO ()
+drawFrame term path scroll ls pg total done mode msg mre = do
+  let (start, end) = visibleLineRange scroll
+      visible = take (end - start) (drop start ls)
+      status = case mode of
+        ModeInput q -> "/" ++ q
+        ModeView -> case msg of
+          Just m -> " " ++ m
+          Nothing ->
+            " page "
+              ++ show (if total > 0 then pg else 0)
+              ++ "/"
+              ++ show total
+              ++ (if done then "" else "...")
+              ++ " | line "
+              ++ show (statusLineNumber scroll)
+              ++ "/"
+              ++ show (max (length ls) 1)
+              ++ " | j/k \x2193/\x2191 d/u g/G /:search n/N q:quit"
+  drawBarRow term (termViewportTop term) (" " ++ path)
+  mapM_ (drawTextRow term mre) (zip [0 ..] visible)
+  let filled = length visible
+      statusRow = termViewportTop term + termViewportH term - 1
+  mapM_ (\i -> drawTextRow term Nothing (i, "")) [filled .. termTextRows term - 1]
+  drawBarRow term statusRow status
+  case mode of
+    ModeInput _ -> do
+      let col = min (termCols term) (stringDisplayWidth status + 1)
+      hPutStr (termStdout term) $
+        "\ESC[" ++ show statusRow ++ ";" ++ show col ++ "H\ESC[?25h"
+    ModeView ->
+      hPutStr (termStdout term) "\ESC[?25l"
+  hFlush (termStdout term)
+
+drawTextRow :: Terminal -> Maybe Regex -> (Int, Text) -> IO ()
+drawTextRow term mre (i, txt) = do
+  let row = termViewportTop term + 1 + i
+      clipped = clipToDisplayWidth (termCols term) (T.unpack txt)
+      rendered = maybe clipped (`highlightMatches` clipped) mre
+  hPutStr (termStdout term) $ "\ESC[" ++ show row ++ ";1H\ESC[K" ++ rendered
+
+highlightMatches :: Regex -> String -> String
+highlightMatches re s = go 0 s spans
+  where
+    spans = [(off, len) | ma <- matchAll re s, let (off, len) = ma ! 0, len > 0]
+    go _ rest [] = rest
+    go pos rest ((off, len) : more) =
+      let (before, rest') = splitAt (off - pos) rest
+          (hit, rest'') = splitAt len rest'
+       in before ++ "\ESC[7m" ++ hit ++ "\ESC[0m" ++ go (off + len) rest'' more
+
+drawBarRow :: Terminal -> Int -> String -> IO ()
+drawBarRow term row content = do
+  let padded = padToDisplayWidth (termCols term) content
+  hPutStr (termStdout term) $
+    "\ESC[" ++ show row ++ ";1H\ESC[7m" ++ padded ++ "\ESC[0m"
+
+clearViewport :: Terminal -> IO ()
+clearViewport term =
+  mapM_ clearRow [termViewportTop term .. termViewportTop term + termViewportH term - 1]
+  where
+    clearRow r = hPutStr (termStdout term) $ "\ESC[" ++ show r ++ ";1H\ESC[K"
diff --git a/app/TuiScroll.hs b/app/TuiScroll.hs
new file mode 100644
--- /dev/null
+++ b/app/TuiScroll.hs
@@ -0,0 +1,118 @@
+module TuiScroll
+  ( ScrollState(..)
+  , initialScrollState
+  , clampScrollTop
+  , scrollBy
+  , scrollToTop
+  , scrollToEnd
+  , scrollHalfPageDown
+  , scrollHalfPageUp
+  , searchForwardFrom
+  , searchBackwardFrom
+  , visibleLineRange
+  , statusLineNumber
+  , charDisplayWidth
+  , stringDisplayWidth
+  , clipToDisplayWidth
+  , padToDisplayWidth
+  ) where
+
+import Data.Char (ord)
+import Data.List (findIndex)
+
+data ScrollState = ScrollState
+  { scrollTopLine  :: !Int
+  , scrollTotalLines :: !Int
+  , scrollTextRows :: !Int
+  } deriving (Eq, Show)
+
+initialScrollState :: Int -> ScrollState
+initialScrollState textRows =
+  ScrollState {scrollTopLine = 0, scrollTotalLines = 0, scrollTextRows = textRows}
+
+maxScrollTop :: ScrollState -> Int
+maxScrollTop st =
+  max 0 (scrollTotalLines st - scrollTextRows st)
+
+clampScrollTop :: Int -> ScrollState -> ScrollState
+clampScrollTop n st = st {scrollTopLine = clamp 0 (maxScrollTop st) n}
+  where
+    clamp lo hi x = max lo (min hi x)
+
+scrollBy :: Int -> ScrollState -> ScrollState
+scrollBy delta st =
+  clampScrollTop (scrollTopLine st + delta) st
+
+scrollToTop :: ScrollState -> ScrollState
+scrollToTop st = st {scrollTopLine = 0}
+
+scrollToEnd :: ScrollState -> ScrollState
+scrollToEnd st = clampScrollTop maxBound st
+
+scrollHalfPageDown :: ScrollState -> ScrollState
+scrollHalfPageDown st =
+  scrollBy (max 1 (scrollTextRows st `div` 2)) st
+
+scrollHalfPageUp :: ScrollState -> ScrollState
+scrollHalfPageUp st =
+  scrollBy (-max 1 (scrollTextRows st `div` 2)) st
+
+-- | Index of the first line at or after @start@ satisfying the predicate.
+searchForwardFrom :: (a -> Bool) -> Int -> [a] -> Maybe Int
+searchForwardFrom p start ls =
+  let from = max 0 start
+   in (+ from) <$> findIndex p (drop from ls)
+
+-- | Index of the last line at or before @start@ satisfying the predicate.
+searchBackwardFrom :: (a -> Bool) -> Int -> [a] -> Maybe Int
+searchBackwardFrom p start ls =
+  let upto = min (length ls - 1) start
+      hits = [i | (i, l) <- zip [0 ..] (take (upto + 1) ls), p l]
+   in if upto < 0 || null hits then Nothing else Just (last hits)
+
+visibleLineRange :: ScrollState -> (Int, Int)
+visibleLineRange st =
+  let top = scrollTopLine st
+      end = min (scrollTotalLines st) (top + scrollTextRows st)
+   in (top, end)
+
+statusLineNumber :: ScrollState -> Int
+statusLineNumber st =
+  if scrollTotalLines st == 0
+    then 0
+    else scrollTopLine st + 1
+
+-- | Terminal display width: East Asian wide/fullwidth characters occupy
+-- two columns. Covers the common CJK blocks; combining marks are not handled.
+charDisplayWidth :: Char -> Int
+charDisplayWidth c
+  | w >= 0x1100 && w <= 0x115F = 2   -- Hangul Jamo
+  | w >= 0x2E80 && w <= 0xA4CF = 2   -- CJK radicals .. Yi
+  | w >= 0xAC00 && w <= 0xD7A3 = 2   -- Hangul syllables
+  | w >= 0xF900 && w <= 0xFAFF = 2   -- CJK compatibility ideographs
+  | w >= 0xFE30 && w <= 0xFE4F = 2   -- CJK compatibility forms
+  | w >= 0xFF00 && w <= 0xFF60 = 2   -- fullwidth forms
+  | w >= 0xFFE0 && w <= 0xFFE6 = 2
+  | w >= 0x20000 && w <= 0x3FFFD = 2 -- CJK extensions
+  | otherwise = 1
+  where w = ord c
+
+stringDisplayWidth :: String -> Int
+stringDisplayWidth = sum . map charDisplayWidth
+
+-- | Longest prefix that fits in @width@ display columns.
+clipToDisplayWidth :: Int -> String -> String
+clipToDisplayWidth width = go width
+  where
+    go _ [] = []
+    go left (c : cs)
+      | cw <= left = c : go (left - cw) cs
+      | otherwise = []
+      where cw = charDisplayWidth c
+
+-- | Clip and right-pad with spaces to exactly @width@ display columns
+-- (may fall one column short when a wide character straddles the edge).
+padToDisplayWidth :: Int -> String -> String
+padToDisplayWidth width s =
+  let clipped = clipToDisplayWidth width s
+   in clipped ++ replicate (width - stringDisplayWidth clipped) ' '
diff --git a/app/hpdft.hs b/app/hpdft.hs
new file mode 100644
--- /dev/null
+++ b/app/hpdft.hs
@@ -0,0 +1,372 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE PackageImports #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import Cli.Common (maybePassword)
+import Cli.Grep (grepPDF)
+import Cli.Misc
+  ( runDiff
+  , runExtractForm
+  , runExtractImages
+  , showContent
+  , showInfo
+  , showOutlines
+  , showRefs
+  , showTitle
+  , showTrailer
+  )
+import Cli.Parser
+  ( Cmd(..)
+  , DiffOpt(..)
+  , ExtractOpt(..)
+  , FormOpt(..)
+  , ImagesOpt(..)
+  , LegacyOpt(..)
+  )
+import Cli.Text (runExtractText)
+import Cli.View (runViewer)
+
+import PDF.Layout (defaultLayoutOptions)
+
+import System.Environment (getArgs)
+import System.Exit (exitWith, ExitCode(..))
+import System.IO (hPutStrLn, stderr)
+
+import Data.Maybe (listToMaybe)
+
+import Options.Applicative
+import Data.Semigroup ((<>))
+
+import qualified Paths_hpdft as Autogen (version)
+import Data.Version (showVersion)
+
+import Control.Monad (when)
+
+deprecationMsg :: String
+deprecationMsg =
+  "hpdft: flat flags are deprecated; use subcommands (e.g. hpdft text, hpdft info)"
+
+subcommandNames :: [String]
+subcommandNames =
+  [ "text", "image", "form"
+  , "diff", "info", "title", "toc", "trailer", "object", "refs", "grep"
+  ]
+
+main :: IO ()
+main = do
+  args <- getArgs
+  when (listToMaybe args == Just "extract") $ do
+    hPutStrLn stderr "hpdft: 'extract' subcommand removed; use text, image, or form"
+    exitWith (ExitFailure 1)
+  let useSubcommands = case listToMaybe args of
+        Just a | a `elem` subcommandNames -> True
+        _ -> False
+      isMeta = any (`elem` args) ["--help", "-h", "--version", "-V"]
+      parserExtras = helper <**> simpleVersioner versionInfo
+  cmd <- if useSubcommands
+         then execParser (info (commandParser <**> parserExtras) (fullDesc <> header versionInfo))
+         else if isMeta
+              then execParser (info (legacyCmd <**> parserExtras) (fullDesc <> header versionInfo))
+              else do
+                when (legacyNeedsDeprecation args) $
+                  hPutStrLn stderr deprecationMsg
+                execParser (info (legacyCmd <**> parserExtras) (fullDesc <> header versionInfo))
+  runCmd cmd
+
+versionInfo :: String
+versionInfo = "hpdft - a PDF to text converter, version " <> showVersion Autogen.version
+
+runCmd :: Cmd -> IO ()
+runCmd cmd = case cmd of
+  CmdText opt -> runExtractText opt
+  CmdView fn mpw h -> runViewer fn mpw h
+  CmdImage opt -> runExtractImages opt
+  CmdForm opt -> runExtractForm opt
+  CmdDiff opt -> runDiff opt
+  CmdInfo fn mpw -> showInfo fn mpw
+  CmdTitle fn mpw -> showTitle fn mpw
+  CmdToc fn mpw -> showOutlines fn mpw
+  CmdTrailer fn -> showTrailer fn
+  CmdObject rf fn mpw -> showContent fn mpw rf
+  CmdRefs fn mpw -> showRefs fn mpw
+  CmdGrep re fn mpw -> grepPDF defaultLayoutOptions fn mpw re
+
+passwordOpt :: Parser String
+passwordOpt = strOption
+  ( long "password"
+    <> short 'P'
+    <> value ""
+    <> metavar "PASSWORD"
+    <> help "Password for encrypted PDF" )
+
+heightOpt :: Parser (Maybe String)
+heightOpt = optional $
+  strOption
+    ( long "height"
+      <> metavar "HEIGHT"
+      <> help "TUI viewport height in terminal rows, or N% (e.g. 50%%, 100%%)" )
+
+fileArg :: Parser FilePath
+fileArg = strArgument
+  ( help "input pdf file"
+    <> metavar "FILE"
+    <> action "file" )
+
+commandParser :: Parser Cmd
+commandParser = subparser
+  (  command "text" (info (CmdText <$> extractOpts) (progDesc "Extract text to stdout (tagged -> geometry)"))
+  <> command "image" (info (CmdImage <$> imagesOpts) (progDesc "Extract image XObjects from a page"))
+  <> command "form" (info (CmdForm <$> formOpts) (progDesc "List or extract top-level Form XObjects on a page"))
+  <> command "diff" (info diffCommand (progDesc "Compare two PDFs (paragraph-level diff)"))
+  <> command "info" (info infoCommand (progDesc "Show PDF metadata"))
+  <> command "title" (info titleCommand (progDesc "Show document title"))
+  <> command "toc" (info tocCommand (progDesc "Show table of contents"))
+  <> command "trailer" (info trailerCommand (progDesc "Show PDF trailer"))
+  <> command "object" (info objectCommand (progDesc "Show object by reference"))
+  <> command "refs" (info refsCommand (progDesc "Show page object references"))
+  <> command "grep" (info grepCommand (progDesc "Search text in PDF"))
+  )
+
+imagesOpts :: Parser ImagesOpt
+imagesOpts = ImagesOpt
+  <$> option auto
+      ( long "page"
+        <> short 'p'
+        <> metavar "PAGE"
+        <> help "Page number (1-based, required)" )
+  <*> strOption
+      ( long "output"
+        <> short 'o'
+        <> value "."
+        <> metavar "DIR"
+        <> help "Output directory (default: current directory)" )
+  <*> passwordOpt
+  <*> fileArg
+
+formOpts :: Parser FormOpt
+formOpts = FormOpt
+  <$> option auto
+      ( long "page"
+        <> short 'p'
+        <> metavar "PAGE"
+        <> help "Page number (1-based, required)" )
+  <*> optional
+      ( strOption
+          ( long "name"
+            <> short 'n'
+            <> metavar "NAME"
+            <> help "Top-level Form XObject name (e.g. Fm42); omit to list extractable names on stdout" ) )
+  <*> strOption
+      ( long "output"
+        <> short 'o'
+        <> value "."
+        <> metavar "DIR"
+        <> help "Output directory (default: current directory)" )
+  <*> passwordOpt
+  <*> fileArg
+
+extractOpts :: Parser ExtractOpt
+extractOpts = ExtractOpt
+  <$> option auto
+      ( long "page"
+        <> short 'p'
+        <> value 0
+        <> metavar "PAGE"
+        <> help "Page number (1-based; 0 = all pages)" )
+  <*> switch
+      ( long "geom"
+        <> help "Extract text using geometry-based layout" )
+  <*> switch
+      ( long "tagged"
+        <> help "Extract text using tagged PDF structure" )
+  <*> switch
+      ( long "legacy"
+        <> help "Extract text using the pre-0.3 stream-order extractor" )
+  <*> switch
+      ( long "quiet"
+        <> help "Suppress page progress on stderr during --legacy streaming" )
+  <*> switch
+      ( long "footnotes"
+        <> help "Inline footnote bodies at their anchors as <footnote> tags (geometry pipeline)" )
+  <*> switch
+      ( long "ruby"
+        <> help "Embed ruby in Aozora bunko notation (geometry/tagged pipeline)" )
+  <*> passwordOpt
+  <*> fileArg
+
+diffCommand :: Parser Cmd
+diffCommand = CmdDiff <$> diffOpts
+
+diffOpts :: Parser DiffOpt
+diffOpts = DiffOpt
+  <$> switch
+      ( long "geom"
+        <> help "Use geometry-based layout (default for diff)" )
+  <*> switch
+      ( long "ruby"
+        <> help "Embed ruby in Aozora bunko notation during layout" )
+  <*> switch
+      ( long "json"
+        <> help "Emit JSON instead of human-readable diff" )
+  <*> passwordOpt
+  <*> strArgument
+      ( help "first PDF file"
+        <> metavar "FILE_A"
+        <> action "file" )
+  <*> strArgument
+      ( help "second PDF file"
+        <> metavar "FILE_B"
+        <> action "file" )
+
+infoCommand :: Parser Cmd
+infoCommand = CmdInfo <$> fileArg <*> (maybePassword <$> passwordOpt)
+
+titleCommand :: Parser Cmd
+titleCommand = CmdTitle <$> fileArg <*> (maybePassword <$> passwordOpt)
+
+tocCommand :: Parser Cmd
+tocCommand = CmdToc <$> fileArg <*> (maybePassword <$> passwordOpt)
+
+trailerCommand :: Parser Cmd
+trailerCommand = CmdTrailer <$> fileArg
+
+objectCommand :: Parser Cmd
+objectCommand = CmdObject
+  <$> option auto
+      ( long "ref"
+        <> short 'r'
+        <> metavar "REF"
+        <> help "Object reference number" )
+  <*> fileArg
+  <*> (maybePassword <$> passwordOpt)
+
+refsCommand :: Parser Cmd
+refsCommand = CmdRefs <$> fileArg <*> (maybePassword <$> passwordOpt)
+
+grepCommand :: Parser Cmd
+grepCommand = CmdGrep
+  <$> strOption
+      ( long "grep"
+        <> short 'g'
+        <> metavar "REGEXP"
+        <> help "Regular expression to search for" )
+  <*> fileArg
+  <*> (maybePassword <$> passwordOpt)
+
+legacyCmd :: Parser Cmd
+legacyCmd = legacyToCmd <$> legacyOptions
+
+legacyOptions :: Parser LegacyOpt
+legacyOptions = LegacyOpt
+  <$> option auto
+      ( long "page"
+        <> short 'p'
+        <> value 0
+        <> metavar "PAGE"
+        <> help "Page number (number)" )
+  <*> option auto
+      ( long "ref"
+        <> short 'r'
+        <> value 0
+        <> metavar "REF"
+        <> help "Object reference" )
+  <*> strOption
+      ( long "grep"
+        <> short 'g'
+        <> value ""
+        <> metavar "RegExp"
+        <> help "grep PDF" )
+  <*> switch
+      ( long "refs"
+        <> short 'R'
+        <> help "Show object references in page order" )
+  <*> switch
+      ( long "geom"
+        <> help "Extract text using geometry-based layout" )
+  <*> switch
+      ( long "tagged"
+        <> help "Extract text using tagged PDF structure" )
+  <*> switch
+      ( long "legacy"
+        <> help "Extract text using the pre-0.3 stream-order extractor" )
+  <*> switch
+      ( long "footnotes"
+        <> help "Inline footnote bodies at their anchors as <footnote> tags (geometry pipeline)" )
+  <*> switch
+      ( long "ruby"
+        <> help "Embed ruby in Aozora bunko notation (geometry/tagged pipeline)" )
+  <*> switch
+      ( long "title"
+        <> short 'T'
+        <> help "Show title (from metadata)" )
+  <*> switch
+      ( long "info"
+        <> short 'I'
+        <> help "Show PDF metainfo" )
+  <*> switch
+      ( long "toc"
+        <> short 'O'
+        <> help "Show table of contents (from metadata) " )
+  <*> switch
+      ( long "trailer"
+        <> help "Show the trailer of PDF" )
+  <*> strOption
+      ( long "height"
+        <> value ""
+        <> metavar "HEIGHT"
+        <> help "TUI viewport height in terminal rows, or N%% (default: half screen)" )
+  <*> passwordOpt
+  <*> strArgument
+      ( help "input pdf file"
+        <> metavar "FILE"
+        <> action "file" )
+
+legacyToCmd :: LegacyOpt -> Cmd
+legacyToCmd LegacyOpt{loPage=pg, loRef=rf, loGrep=gr, loRefs=rs, loGeom=gm,
+                      loTagged=tg, loLegacy=lg, loFootnotes=fnn, loRuby=rb,
+                      loTitle=tt, loInfo=ii, loToc=oo, loTrailer=tr,
+                      loHeight=h, loPassword=pw, loFile=fn} =
+  let mpw = maybePassword pw
+      mHeight = if null h then Nothing else Just h
+      noMode = not gm && not tg && not lg
+      plainView = noMode && not fnn && not rb
+      textCmd = CmdText (ExtractOpt pg gm tg lg False fnn rb pw fn)
+  in case () of
+    _ | pg==0 && rf==0 && null gr && not rs && lg && not gm && not tg && not tt && not ii && not oo && not tr ->
+        textCmd
+      | pg==0 && rf==0 && null gr && not rs && gm && not tg && not lg && not tt && not ii && not oo && not tr ->
+        textCmd
+      | pg==0 && rf==0 && null gr && not rs && plainView && not tt && not ii && not oo && not tr ->
+        CmdView fn mpw mHeight
+      | pg==0 && rf==0 && null gr && not rs && (tg || noMode) && not gm && not lg && not tt && not ii && not oo && not tr ->
+        textCmd
+      | pg==0 && rf==0 && null gr && not rs && noMode && tt ->
+        CmdTitle fn mpw
+      | pg==0 && rf==0 && null gr && not rs && noMode && ii ->
+        CmdInfo fn mpw
+      | pg==0 && rf==0 && null gr && not rs && noMode && oo ->
+        CmdToc fn mpw
+      | pg==0 && rf==0 && null gr && not rs && noMode && tr ->
+        CmdTrailer fn
+      | pg==0 && rf==0 && null gr && rs && noMode ->
+        CmdRefs fn mpw
+      | rf==0 && null gr && pg/=0 ->
+        textCmd
+      | pg==0 && null gr && rf/=0 ->
+        CmdObject rf fn mpw
+      | pg==0 && rf==0 && not (null gr) ->
+        CmdGrep gr fn mpw
+      | otherwise ->
+        textCmd
+
+legacyNeedsDeprecation :: [String] -> Bool
+legacyNeedsDeprecation args =
+  any (`elem` args)
+    [ "-I","--info","-T","--title","-O","--toc","--trailer","-R","--refs","-g","--grep" ]
+  || containsFlag args "-r"
+
+containsFlag :: [String] -> String -> Bool
+containsFlag [] _ = False
+containsFlag (a:as) f = a == f || containsFlag as f
diff --git a/dev/0.5-roadmap.md b/dev/0.5-roadmap.md
new file mode 100644
--- /dev/null
+++ b/dev/0.5-roadmap.md
@@ -0,0 +1,172 @@
+# 0.5 ロードマップ — layout モードとコードベース改善
+
+2026-07-06 時点の方針検討メモ。0.4.6.4 のコードベースレビュー結果（A 節）と、0.5 の新機能（B〜D 節）の実装方針をまとめる。**A 節と C 節は 0.4.7.0 として実装済み**（下記マイルストーン表参照）。B 節と D 節の新機能は 0.5.0 から着手する。
+
+## 位置づけ
+
+0.4 系は「抽出パイプラインの品質と性能」（geom 高速化、TUI、CMap 対応、パーサ堅牢化）に加え、**0.4.7 でコードベース整備（A 節）と TUI 高さ指定（C 節）を完了**した。0.5 は次を主眼とする。
+
+1. **見た目再現の layout モード**（pdftotext -layout 相当だが、東アジア文字幅と罫線に対応した実用版）
+2. **テキスト抽出 UX の拡張**（`-o` ファイル出力、ページ範囲、grep 強化、JSON 等 — D 節）
+
+`dev/0.5-text-ux.md` の持ち越し分（UX-3: `-o` ファイル出力）も 0.5 に含める。
+
+---
+
+## A. 既存コードの改善（0.4.7.0 で実装済み）
+
+0.4.6.4 時点のレビュー結果。以下は **0.4.7.0** で対応済み（CHANGELOG 参照）。
+
+### A1. 字句解析の共通化（P0） — **実装済み**
+
+`PDF.StreamLex` に数値正規化・hex 分解・UTF-16BE / SJIS / JIS コード分割を集約。`ContentStream` / `Interpret` は import のみ。ユニットテストは `StreamLex` 1 系統。
+
+### A2. ページツリー走査の一本化（P0） — **実装済み**
+
+`Page.pageRefsFromRoot` を export。`Text.walkdown` と CLI（`app/Cli/*`）はこれを利用。`pageorder` / `PageTree` は削除。
+
+### A3. サイレント失敗の可視化（P0） — **実装済み**
+
+`PageContentFailed` warning を追加。legacy の `pageContent` と grep の geom 失敗が stderr に出る。
+
+### A4. エラー処理の統一（P1） — **実装済み**
+
+`parseObjStmHeader` / `parseObjStmValue` を `PdfResult` 化。`Encrypt.hexToBytes` を total 化。
+
+### A5. テストの穴（P1） — **実装済み**
+
+golden に geom モード（`expected-geom/`）を追加。`test/EncryptSpec.hs` で RC4 鍵ストリームをテスト。
+
+### A6. CLI の整理（P2） — **実装済み**
+
+`app/Cli/*` に分割。`diff --legacy` 削除、`-p` ヘルプ typo 修正、`passwordOpt` 共通化。
+
+### A7. パッケージ構成（P2） — **実装済み**
+
+TUI / CLI を `app/` へ移動。`PDF.ContentStream` を other-modules 化。library に `-O2` を追加（executable からは除去）。
+
+### A8. 性能（P2） — **実装済み**
+
+`StreamLex.parsePdfNumberFromByteString` を `Interpret.readNumber` から利用。
+
+---
+
+## B. 新機能: layout モード（見た目再現テキスト）
+
+### 目標
+
+`hpdft text --layout FILE` で、ページの視覚配置を等幅テキストとして再現する。pdftotext -layout 相当だが、次の点で「より実用的」を狙う。
+
+1. **東アジア文字幅**: CJK 文字を 2 セル幅として桁位置を計算する（pdftotext は日本語 PDF で桁がずれる）。幅計算は `TuiScroll.charDisplayWidth` を共有モジュールへ昇格して流用
+2. **罫線の再現**（Phase 2）: geom パイプラインは既に `ItemGraphic`（矩形）を持っている。表の罫線を `─ │ ┌ ┼` 等の罫線素片で描画できれば、表組みの再現性で pdftotext を明確に超えられる
+3. **縦書きの扱いを明示**: 混在ページで壊れず、少なくとも読み順テキストに劣化する
+
+### 現状の材料と足りないもの
+
+geom パイプラインの `Glyph`（Interpret.hs 56–65）に座標・advance 幅・フォントサイズ・wmode が揃っており、入力データは十分。ただし:
+
+- **`Line`（Layout.hs 855–865）は入力に使えない**。`buildLines` はストリーム順 fold で同一 baseline のグリフを 1 本に連結するため、行内の大きな空隙（多段組の列間、表のセル間）が失われている。layout モードは Glyph 列から直接「セグメント」（baseline が同じで inline 方向の隙間が閾値以下の連続 run）を作る必要がある
+- **MediaBox / `/Rotate` が未実装**。現状 `src/` のどこもページ属性を読んでいない（ページ幅は glyph bbox からの近似のみ）。安定した紙面座標系のため、ページ辞書から MediaBox と Rotate を取得し、Rotate は CTM 前置で正規化する。A2 の走査一本化に載せる
+
+### 設計
+
+新モジュール `PDF.Render`（仮）に実装。tagged パスは MCID 結合で座標を捨てるため、**layout は geom 専用**とする。
+
+```haskell
+data RenderOptions = RenderOptions
+  { renderWidth  :: Maybe Int   -- 出力桁数。Nothing なら本文フォントから自動
+  , renderRules  :: Bool        -- 罫線描画（Phase 2）
+  }
+
+pageTextLayout :: Document -> PageRef -> RenderOptions -> PdfResult T.Text
+```
+
+手順:
+
+1. `pageItems` から Glyph を取り、`filterPageGlyphs` 相当のノイズ除去後、セグメント化（baseline 差 0.4×size 以内 & inline gap が `0.6 × size` 超で分割。閾値は fixture で調整）
+2. **スケール決定**: 横 1 セル = `本文サイズの中央値 × 0.5` pt、縦 1 行 = `行送りの中央値`。`--width N` 指定時は `MediaBox 幅 / N` をセル幅にする
+3. **行配置**: セグメントを baseline でクラスタし、行クラスタ間の gap から空行数を決める（`max 0 (round (gap / 行送り) - 1)`）
+4. **桁配置**: 各セグメントを `round ((x - 左端) / セル幅)` 桁目に置く。East Asian width で埋めながら、先行セグメントと重なる場合は右へ 1 セル以上ずらす（重ね書きより順序保持を優先）
+5. 行末の空白は落とす。全ページを `\f`（form feed、pdftotext 互換）区切りで連結
+
+縦書き（wmode 1）ページ: Phase 1 では格子配置をせず、既存の読み順ソート（右列→左列）による段落テキストへフォールバックし、stderr に注記を出す。転置レンダリング（縦列→横行）は需要を見て Phase 3。
+
+### CLI / API
+
+- `hpdft text --layout [-w N] FILE`（`--geom` らと排他）。`-p` 併用可
+- `PDF.Page` に `pageLayoutText` の薄いラッパを追加
+- 既存 `LayoutOptions`（ルビ・脚注）とは概念が別なので混ぜない。`--layout` 時は `--ruby` / `--footnotes` を無効とする（リフローしないのでルビは位置のまま出る）
+
+### テスト
+
+- fixture: 2 段組、表（罫線つき）、インデント付きコード、日本語混在の 4 種を最小 PDF で作成し golden に `expected-layout/` を追加（A5 の geom golden を兼ねる）
+- ユニット: スケール決定・桁配置・重なり回避を純関数として切り出してテスト
+
+### フェーズ
+
+| Phase | 内容 |
+|-------|------|
+| 1 | MediaBox/Rotate 取得、セグメント化、格子レンダラ、`--width`、fixture |
+| 2 | 罫線描画（`ItemGraphic` → 罫線素片）、表の桁揃え改善 |
+| 3 | 縦書き転置、TUI からの layout 表示 |
+
+---
+
+## C. TUI の表示高さ指定 — **実装済み（0.4.7.0）**
+
+`--height N`（行数）または `N%`（端末高さの割合）。`app/TuiGeometry.hs` でパース・クランプ。未指定時は従来どおり下半分。SIGWINCH は見送り。
+
+---
+
+## D. そのほかの機能候補
+
+「PDF をテキストとして扱う」用途で需要がありそうなものを優先度順に。
+
+### D1. `-o` ファイル出力（UX-3 持ち越し、P1）
+
+`dev/0.5-text-ux.md` で設計済み・未実装。`text -o OUT FILE` で tagged→geom の高品質全文をファイルへ、`OUT.tmp` → rename の原子的書き込み、進捗は stderr。設計変更なしで実装のみ。
+
+### D2. ページ範囲指定（P1）
+
+現状 `-p N` は単一ページのみ。`-p 3-10`、`-p 5-`、`-p 1,3,7-9` を受け付ける。`text` / `image` / `grep` で共通に使えるようパーサを 1 箇所に置く。実装が軽く、pdftotext の `-f`/`-l` 相当として要望されやすい。
+
+### D3. JSON 構造化出力（P2）
+
+`pageRegions`（ページ番号・段落番号・bbox・テキスト）が既に公開 API にあるので、`text --json` でそのまま JSON 化するだけで下流ツール（RAG の前処理、検索インデクサ）に使える。aeson 依存を足すか、手書きシリアライザで依存ゼロを保つかは要判断（現状の依存ポリシーは軽量指向なので手書き寄り）。
+
+### D4. grep の強化（P2）
+
+既存 `grep` サブコマンドはマッチ行を出すのみ。`-n`（ページ番号:行番号）、`-C N`（前後行）、`-c`（件数）を追加。A3 の失敗可視化とセットで「検索漏れが起きない」ことを保証する。デフォルトを legacy パス（高速）にし、`--geom` で高品質に切り替え。
+
+### D5. リンク・注釈の抽出（P2）
+
+`hpdft links FILE`: `/Annots` の URI / GoTo リンクをページ番号・矩形・アンカーテキスト（矩形内の Glyph から復元）付きで列挙。目次（`toc`）と補完的で、文書の外部参照一覧を作る用途に需要がある。geom パイプラインの座標がそのまま使える。
+
+### D6. 幅精度の改善（P3、cmap-support.md との連動）
+
+`SJISmap` / `JISmap` はコード→CID 変換を持たないため `/W` を引けず `/DW` 代用（cmap-support.md P3 に記載）。layout モードでは幅誤差が桁ズレとして直接見えるため、B 節 Phase 2 の結果を見て優先度を再評価する。
+
+### 見送り
+
+- **RTL / BiDi**: 対象コーパスにないため引き続きスコープ外
+- **pdftotext 互換フラグ群**（-htmlmeta 等）: 需要が出てから
+- **SIGWINCH 対応**: C 節に記載のとおり見送り
+
+---
+
+## マイルストーン案
+
+| 順 | 版 | 内容 |
+|----|-----|------|
+| 0 | **0.4.7.x** | **A1–A8 + C**（字句共通化、走査一本化、失敗可視化、エラー統一、テスト補強、CLI/パッケージ整理、性能、TUI 高さ指定）— **完了** |
+| 1 | 0.5.0 | B Phase 1: `--layout` MVP（MediaBox/Rotate、格子レンダラ、fixture）+ D 節新機能の実装開始 |
+| 2 | 0.5.1 | B Phase 2: 罫線描画 |
+| 3 | 0.5.2 | D1 `-o` + D2 ページ範囲 |
+| 4 | 0.5.3 | D4 grep 強化 + B Phase 3 要素 |
+| 5 | 0.5.x | D3 JSON / D5 links / 縦書き転置（需要を見て） |
+
+## 参照
+
+- [0.5-text-ux.md](0.5-text-ux.md) — TUI / 出力 UX の設計（0.4.6 で大半実装済み、UX-3 のみ持ち越し）
+- [cmap-support.md](cmap-support.md) — CMap 対応の優先度（D6 と連動）
+- [performance-0.4.md](performance-0.4.md) — geom 高速化の経緯（A8 の前提）
diff --git a/dev/README.md b/dev/README.md
--- a/dev/README.md
+++ b/dev/README.md
@@ -7,6 +7,8 @@
 | [performance-0.4.md](performance-0.4.md) | 0.4.5 前後の全文テキスト抽出の高速化（調査・対策・計測） |
 | [0.3-roadmap.md](0.3-roadmap.md) | 0.3.0.0 までの開発ロードマップ（完了） |
 | [0.4-roadmap.md](0.4-roadmap.md) | 0.4 系 API / diff / images / form の計画 |
-| [0.5-text-ux.md](0.5-text-ux.md) | 0.5 候補: CLI 出力 UX（TUI プレビュー / `-o` ファイル出力） |
+| [0.5-text-ux.md](0.5-text-ux.md) | CLI 出力 UX（TUI プレビューは 0.4.6 で実装済み / `-o` は 0.5 へ持ち越し） |
+| [0.5-roadmap.md](0.5-roadmap.md) | 0.5 計画: layout モード、TUI 高さ指定（0.4.7 で整備済み）、D 節新機能 |
+| [cmap-support.md](cmap-support.md) | 定義済み CMap の対応状況と追加対応の優先度 |
 
 ベンチマーク用 PDF（`data/sample/book.pdf` など）は `.gitignore` 対象のためリポジトリには含めません。手元に置いて `scripts/bench_book.sh` を実行してください。
diff --git a/examples/page-api/Main.hs b/examples/page-api/Main.hs
--- a/examples/page-api/Main.hs
+++ b/examples/page-api/Main.hs
@@ -13,9 +13,8 @@
 
 import PDF.Document (openDocument)
 import PDF.Error (PdfResult)
-import PDF.Interpret (Rect(..))
 import PDF.Layout (LayoutOptions(..), defaultLayoutOptions)
-import PDF.Page (PageRegion(..), pageCount, pageParagraphs, pageRefAt, pageRegions)
+import PDF.Page (PageRegion(..), Rect(..), pageCount, pageParagraphs, pageRefAt, pageRegions)
 
 import Control.Monad (forM_)
 import System.Directory (doesFileExist, getCurrentDirectory)
diff --git a/hpdft.cabal b/hpdft.cabal
--- a/hpdft.cabal
+++ b/hpdft.cabal
@@ -1,6 +1,6 @@
 cabal-version:       3.8
 name:                hpdft
-version:             0.4.6.4
+version:             0.4.7.0
 synopsis:            PDF parsing library and CLI for text, layout, diff, images, and forms
 description:
     hpdft is a Haskell library and command-line tool for parsing PDF files.
@@ -25,6 +25,7 @@
                      , dev/0.3-roadmap.md
                      , dev/0.4-roadmap.md
                      , dev/0.5-text-ux.md
+                     , dev/0.5-roadmap.md
                      , scripts/bench_book.sh
                      , scripts/bench_pages.hs
                      , examples/extract-text/Main.hs
@@ -43,9 +44,10 @@
                        
 library
   hs-source-dirs:      src/
+  ghc-options:         -O2
   exposed-modules:     PDF.PDFIO
                      , PDF.Document
-                     , PDF.ContentStream
+                     , PDF.StreamLex
                      , PDF.Character
                      , PDF.Cmap
                      , PDF.Definition
@@ -66,7 +68,8 @@
                      , PDF.Interpret
                      , PDF.Layout
                      , PDF.Structure
-  other-modules:       Paths_hpdft
+  other-modules:       PDF.ContentStream
+                     , Paths_hpdft
   other-extensions:    OverloadedStrings
   build-depends:       array >= 0.5 && < 0.6
                      , attoparsec >= 0.14.4 && < 0.15
@@ -94,11 +97,18 @@
 
 executable hpdft
   main-is:           hpdft.hs
-  ghc-options:       -O2 -threaded -rtsopts "-with-rtsopts=-N -A64m"
-  hs-source-dirs:    .
+  ghc-options:       -threaded -rtsopts "-with-rtsopts=-N -A64m"
+  hs-source-dirs:    app
   other-modules:     Paths_hpdft
                      , TuiPreview
                      , TuiScroll
+                     , TuiGeometry
+                     , Cli.Common
+                     , Cli.Grep
+                     , Cli.Misc
+                     , Cli.Parser
+                     , Cli.Text
+                     , Cli.View
   other-extensions:    OverloadedStrings
   build-depends:       array >= 0.5 && < 0.6
                      , base >= 4.18.0 && < 4.23
@@ -132,7 +142,7 @@
   type:                exitcode-stdio-1.0
   main-is:             Unit.hs
   hs-source-dirs:      test
-                     , .
+                     , app
   ghc-options:         -threaded -rtsopts "-with-rtsopts=-N -A64m"
   build-depends:       base >= 4.18.0 && < 4.23
                      , bytestring >= 0.11.4 && < 0.13
@@ -142,6 +152,8 @@
                      , hpdft
                      , text >= 2.0.2 && < 2.2.2
   other-modules:       TuiScroll
+                     , TuiGeometry
+                     , EncryptSpec
   default-language:    Haskell2010
 
 executable interpret-page
diff --git a/hpdft.hs b/hpdft.hs
deleted file mode 100644
--- a/hpdft.hs
+++ /dev/null
@@ -1,806 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE PackageImports #-}
-{-# LANGUAGE OverloadedStrings #-}
-
-module Main where
-
-import PDF.Definition
-
-import PDF.Object
-import PDF.Document (Document(..), openDocument, docRootRef, docInfoDict)
-import PDF.DocumentStructure
-import PDF.PDFIO
-import PDF.Outlines
-import PDF.Encrypt (Security)
-import PDF.Text (pdfToTextGeomBSWith, pdfToTextTaggedBSWith, pageTextGeomWith, pdfToTextStreamDoc)
-import PDF.Layout (LayoutOptions(..), defaultLayoutOptions)
-import PDF.Diff (TextChange(..), compareDocuments)
-import PDF.Image (extractPageImagesToDir)
-import PDF.FormExtract (pageFormNames, extractFormToFile)
-import PDF.Error (PdfError(..), PdfResult, PdfWarning(..), renderPdfWarning)
-
-import System.Environment (getArgs)
-import System.Exit (exitWith, ExitCode(..))
-import System.IO (hPutStr, hPutStrLn, stderr, stdout, hFlush, hIsTerminalDevice)
-import System.IO.Error (isDoesNotExistError)
-import Control.Exception (catch, IOException, ioError)
-import Control.Monad (when)
-
-import Data.ByteString.UTF8 (ByteString)
-import qualified Data.ByteString.Char8 as BS
-import qualified Data.ByteString.Lazy.Char8 as BSL
-import Data.Text.Lazy as TL (unpack)
-import Data.Text.Lazy.Encoding as TL
-import qualified Data.Text as T
-import Data.List (find, intercalate)
-import Data.Maybe (listToMaybe)
-
-import Options.Applicative
-import Data.Semigroup ((<>))
-
-import Text.Regex.Base.RegexLike ( makeRegex )
-import Text.Regex.TDFA.String    ( regexec )
-import TuiPreview (runTuiPreview)
-
-import PDF.Definition (Obj(PdfStream))
-import Data.IORef (newIORef, readIORef, writeIORef)
-
-import qualified Paths_hpdft as Autogen (version)
-import Data.Version (showVersion)
-
-deprecationMsg :: String
-deprecationMsg =
-  "hpdft: flat flags are deprecated; use subcommands (e.g. hpdft text, hpdft info)"
-
-subcommandNames :: [String]
-subcommandNames =
-  [ "text", "image", "form"
-  , "diff", "info", "title", "toc", "trailer", "object", "refs", "grep"
-  ]
-
-main :: IO ()
-main = do
-  args <- getArgs
-  when (listToMaybe args == Just "extract") $ do
-    hPutStrLn stderr "hpdft: 'extract' subcommand removed; use text, image, or form"
-    exitWith (ExitFailure 1)
-  let useSubcommands = case listToMaybe args of
-        Just a | a `elem` subcommandNames -> True
-        _ -> False
-      isMeta = any (`elem` args) ["--help", "-h", "--version", "-V"]
-      parserExtras = helper <**> simpleVersioner versionInfo
-  cmd <- if useSubcommands
-         then execParser (info (commandParser <**> parserExtras) (fullDesc <> header versionInfo))
-         else if isMeta
-              then execParser (info (legacyCmd <**> parserExtras) (fullDesc <> header versionInfo))
-              else do
-                when (legacyNeedsDeprecation args) $
-                  hPutStrLn stderr deprecationMsg
-                execParser (info (legacyCmd <**> parserExtras) (fullDesc <> header versionInfo))
-  runCmd cmd
-
-versionInfo :: String
-versionInfo = "hpdft - a PDF to text converter, version " <> showVersion Autogen.version
-
--- | Top-level command dispatch.
-data Cmd
-  = CmdText ExtractOpt
-  | CmdView FilePath (Maybe String)
-  | CmdImage ImagesOpt
-  | CmdForm FormOpt
-  | CmdDiff DiffOpt
-  | CmdInfo FilePath (Maybe String)
-  | CmdTitle FilePath (Maybe String)
-  | CmdToc FilePath (Maybe String)
-  | CmdTrailer FilePath
-  | CmdObject Int FilePath (Maybe String)
-  | CmdRefs FilePath (Maybe String)
-  | CmdGrep String FilePath (Maybe String)
-
-data ExtractOpt = ExtractOpt
-  { eoPage      :: Int
-  , eoGeom      :: Bool
-  , eoTagged    :: Bool
-  , eoLegacy    :: Bool
-  , eoQuiet     :: Bool
-  , eoFootnotes :: Bool
-  , eoRuby      :: Bool
-  , eoPassword  :: String
-  , eoFile      :: FilePath
-  }
-
-data ImagesOpt = ImagesOpt
-  { ioPage     :: Int
-  , ioOut      :: FilePath
-  , ioPassword :: String
-  , ioFile     :: FilePath
-  }
-
-data FormOpt = FormOpt
-  { foPage     :: Int
-  , foName     :: Maybe String
-  , foOut      :: FilePath
-  , foPassword :: String
-  , foFile     :: FilePath
-  }
-
-data DiffOpt = DiffOpt
-  { doGeom      :: Bool
-  , doLegacy    :: Bool
-  , doRuby      :: Bool
-  , doJson      :: Bool
-  , doPassword  :: String
-  , doFileA     :: FilePath
-  , doFileB     :: FilePath
-  }
-
-commandParser :: Parser Cmd
-commandParser = subparser
-  (  command "text" (info (CmdText <$> extractOpts) (progDesc "Extract text to stdout (tagged -> geometry)"))
-  <> command "image" (info (CmdImage <$> imagesOpts) (progDesc "Extract image XObjects from a page"))
-  <> command "form" (info (CmdForm <$> formOpts) (progDesc "List or extract top-level Form XObjects on a page"))
-  <> command "diff" (info diffCommand (progDesc "Compare two PDFs (paragraph-level diff)"))
-  <> command "info" (info infoCommand (progDesc "Show PDF metadata"))
-  <> command "title" (info titleCommand (progDesc "Show document title"))
-  <> command "toc" (info tocCommand (progDesc "Show table of contents"))
-  <> command "trailer" (info trailerCommand (progDesc "Show PDF trailer"))
-  <> command "object" (info objectCommand (progDesc "Show object by reference"))
-  <> command "refs" (info refsCommand (progDesc "Show page object references"))
-  <> command "grep" (info grepCommand (progDesc "Search text in PDF"))
-  )
-
-imagesOpts :: Parser ImagesOpt
-imagesOpts = ImagesOpt
-  <$> option auto
-      ( long "page"
-        <> short 'p'
-        <> metavar "PAGE"
-        <> help "Page number (1-based, required)" )
-  <*> strOption
-      ( long "output"
-        <> short 'o'
-        <> value "."
-        <> metavar "DIR"
-        <> help "Output directory (default: current directory)" )
-  <*> strOption
-      ( long "password"
-        <> short 'P'
-        <> value ""
-        <> metavar "PASSWORD"
-        <> help "Password for encrypted PDF" )
-  <*> strArgument
-      ( help "input pdf file"
-        <> metavar "FILE"
-        <> action "file" )
-formOpts :: Parser FormOpt
-formOpts = FormOpt
-  <$> option auto
-      ( long "page"
-        <> short 'p'
-        <> metavar "PAGE"
-        <> help "Page number (1-based, required)" )
-  <*> optional
-      ( strOption
-          ( long "name"
-            <> short 'n'
-            <> metavar "NAME"
-            <> help "Top-level Form XObject name (e.g. Fm42); omit to list extractable names on stdout" ) )
-  <*> strOption
-      ( long "output"
-        <> short 'o'
-        <> value "."
-        <> metavar "DIR"
-        <> help "Output directory (default: current directory)" )
-  <*> strOption
-      ( long "password"
-        <> short 'P'
-        <> value ""
-        <> metavar "PASSWORD"
-        <> help "Password for encrypted PDF" )
-  <*> strArgument
-      ( help "input pdf file"
-        <> metavar "FILE"
-        <> action "file" )
-extractOpts :: Parser ExtractOpt
-extractOpts = ExtractOpt
-  <$> option auto
-      ( long "page"
-        <> short 'p'
-        <> value 0
-        <> metavar "PAGE"
-        <> help "Page number (1-based; 0 = all pages)" )
-  <*> switch
-      ( long "geom"
-        <> help "Extract text using geometry-based layout" )
-  <*> switch
-      ( long "tagged"
-        <> help "Extract text using tagged PDF structure" )
-  <*> switch
-      ( long "legacy"
-        <> help "Extract text using the pre-0.3 stream-order extractor" )
-  <*> switch
-      ( long "quiet"
-        <> help "Suppress page progress on stderr during --legacy streaming" )
-  <*> switch
-      ( long "footnotes"
-        <> help "Inline footnote bodies at their anchors as <footnote> tags (geometry pipeline)" )
-  <*> switch
-      ( long "ruby"
-        <> help "Embed ruby in Aozora bunko notation (geometry/tagged pipeline)" )
-  <*> strOption
-      ( long "password"
-        <> short 'P'
-        <> value ""
-        <> metavar "PASSWORD"
-        <> help "Password for encrypted PDF" )
-  <*> strArgument
-      ( help "input pdf file"
-        <> metavar "FILE"
-        <> action "file" )
-
-diffCommand :: Parser Cmd
-diffCommand = CmdDiff <$> diffOpts
-
-diffOpts :: Parser DiffOpt
-diffOpts = DiffOpt
-  <$> switch
-      ( long "geom"
-        <> help "Use geometry-based layout (default for diff)" )
-  <*> switch
-      ( long "legacy"
-        <> help "Ignored for diff (geometry pipeline always used)" )
-  <*> switch
-      ( long "ruby"
-        <> help "Embed ruby in Aozora bunko notation during layout" )
-  <*> switch
-      ( long "json"
-        <> help "Emit JSON instead of human-readable diff" )
-  <*> strOption
-      ( long "password"
-        <> short 'P'
-        <> value ""
-        <> metavar "PASSWORD"
-        <> help "Password for encrypted PDF (applied to both files)" )
-  <*> strArgument
-      ( help "first PDF file"
-        <> metavar "FILE_A"
-        <> action "file" )
-  <*> strArgument
-      ( help "second PDF file"
-        <> metavar "FILE_B"
-        <> action "file" )
-
-passwordOpt :: Parser String
-passwordOpt = strOption
-  ( long "password"
-    <> short 'P'
-    <> value ""
-    <> metavar "PASSWORD"
-    <> help "Password for encrypted PDF" )
-
-fileArg :: Parser FilePath
-fileArg = strArgument
-  ( help "input pdf file"
-    <> metavar "FILE"
-    <> action "file" )
-
-maybePassword :: String -> Maybe String
-maybePassword pw = if null pw then Nothing else Just pw
-
-infoCommand :: Parser Cmd
-infoCommand = CmdInfo <$> fileArg <*> (maybePassword <$> passwordOpt)
-
-titleCommand :: Parser Cmd
-titleCommand = CmdTitle <$> fileArg <*> (maybePassword <$> passwordOpt)
-
-tocCommand :: Parser Cmd
-tocCommand = CmdToc <$> fileArg <*> (maybePassword <$> passwordOpt)
-
-trailerCommand :: Parser Cmd
-trailerCommand = CmdTrailer <$> fileArg
-
-objectCommand :: Parser Cmd
-objectCommand = CmdObject
-  <$> option auto
-      ( long "ref"
-        <> short 'r'
-        <> metavar "REF"
-        <> help "Object reference number" )
-  <*> fileArg
-  <*> (maybePassword <$> passwordOpt)
-
-refsCommand :: Parser Cmd
-refsCommand = CmdRefs <$> fileArg <*> (maybePassword <$> passwordOpt)
-
-grepCommand :: Parser Cmd
-grepCommand = CmdGrep
-  <$> strOption
-      ( long "grep"
-        <> short 'g'
-        <> metavar "REGEXP"
-        <> help "Regular expression to search for" )
-  <*> fileArg
-  <*> (maybePassword <$> passwordOpt)
-
--- Legacy flat-flag parser (deprecated).
-legacyCmd :: Parser Cmd
-legacyCmd = legacyToCmd <$> legacyOptions
-
-legacyOptions :: Parser LegacyOpt
-legacyOptions = LegacyOpt
-  <$> option auto
-      ( long "page"
-        <> short 'p'
-        <> value 0
-        <> metavar "PAGE"
-        <> help "Page number (nomble)" )
-  <*> option auto
-      ( long "ref"
-        <> short 'r'
-        <> value 0
-        <> metavar "REF"
-        <> help "Object reference" )
-  <*> strOption
-      ( long "grep"
-        <> short 'g'
-        <> value ""
-        <> metavar "RegExp"
-        <> help "grep PDF" )
-  <*> switch
-      ( long "refs"
-        <> short 'R'
-        <> help "Show object references in page order" )
-  <*> switch
-      ( long "geom"
-        <> help "Extract text using geometry-based layout" )
-  <*> switch
-      ( long "tagged"
-        <> help "Extract text using tagged PDF structure" )
-  <*> switch
-      ( long "legacy"
-        <> help "Extract text using the pre-0.3 stream-order extractor" )
-  <*> switch
-      ( long "footnotes"
-        <> help "Inline footnote bodies at their anchors as <footnote> tags (geometry pipeline)" )
-  <*> switch
-      ( long "ruby"
-        <> help "Embed ruby in Aozora bunko notation (geometry/tagged pipeline)" )
-  <*> switch
-      ( long "title"
-        <> short 'T'
-        <> help "Show title (from metadata)" )
-  <*> switch
-      ( long "info"
-        <> short 'I'
-        <> help "Show PDF metainfo" )
-  <*> switch
-      ( long "toc"
-        <> short 'O'
-        <> help "Show table of contents (from metadata) " )
-  <*> switch
-      ( long "trailer"
-        <> help "Show the trailer of PDF" )
-  <*> strOption
-      ( long "password"
-        <> short 'P'
-        <> value ""
-        <> metavar "PASSWORD"
-        <> help "Password for encrypted PDF" )
-  <*> strArgument
-      ( help "input pdf file"
-        <> metavar "FILE"
-        <> action "file" )
-
-data LegacyOpt = LegacyOpt
-  { loPage      :: Int
-  , loRef       :: Int
-  , loGrep      :: String
-  , loRefs      :: Bool
-  , loGeom      :: Bool
-  , loTagged    :: Bool
-  , loLegacy    :: Bool
-  , loFootnotes :: Bool
-  , loRuby      :: Bool
-  , loTitle     :: Bool
-  , loInfo      :: Bool
-  , loToc       :: Bool
-  , loTrailer   :: Bool
-  , loPassword  :: String
-  , loFile      :: FilePath
-  }
-
-legacyToCmd :: LegacyOpt -> Cmd
-legacyToCmd LegacyOpt{loPage=pg, loRef=rf, loGrep=gr, loRefs=rs, loGeom=gm,
-                      loTagged=tg, loLegacy=lg, loFootnotes=fnn, loRuby=rb,
-                      loTitle=tt, loInfo=ii, loToc=oo, loTrailer=tr,
-                      loPassword=pw, loFile=fn} =
-  let mpw = maybePassword pw
-      noMode = not gm && not tg && not lg
-      plainView = noMode && not fnn && not rb
-      textCmd = CmdText (ExtractOpt pg gm tg lg False fnn rb pw fn)
-  in case () of
-    _ | pg==0 && rf==0 && null gr && not rs && lg && not gm && not tg && not tt && not ii && not oo && not tr ->
-        textCmd
-      | pg==0 && rf==0 && null gr && not rs && gm && not tg && not lg && not tt && not ii && not oo && not tr ->
-        textCmd
-      | pg==0 && rf==0 && null gr && not rs && plainView && not tt && not ii && not oo && not tr ->
-        CmdView fn mpw
-      | pg==0 && rf==0 && null gr && not rs && (tg || noMode) && not gm && not lg && not tt && not ii && not oo && not tr ->
-        textCmd
-      | pg==0 && rf==0 && null gr && not rs && noMode && tt ->
-        CmdTitle fn mpw
-      | pg==0 && rf==0 && null gr && not rs && noMode && ii ->
-        CmdInfo fn mpw
-      | pg==0 && rf==0 && null gr && not rs && noMode && oo ->
-        CmdToc fn mpw
-      | pg==0 && rf==0 && null gr && not rs && noMode && tr ->
-        CmdTrailer fn
-      | pg==0 && rf==0 && null gr && rs && noMode ->
-        CmdRefs fn mpw
-      | rf==0 && null gr && pg/=0 ->
-        textCmd
-      | pg==0 && null gr && rf/=0 ->
-        CmdObject rf fn mpw
-      | pg==0 && rf==0 && not (null gr) ->
-        CmdGrep gr fn mpw
-      | otherwise ->
-        textCmd
-
-legacyNeedsDeprecation :: [String] -> Bool
-legacyNeedsDeprecation args =
-  any (`elem` args)
-    [ "-I","--info","-T","--title","-O","--toc","--trailer","-R","--refs","-g","--grep" ]
-  || containsFlag args "-r"
-
-containsFlag :: [String] -> String -> Bool
-containsFlag [] _ = False
-containsFlag (a:as) f = a == f || containsFlag as f
-
-runCmd :: Cmd -> IO ()
-runCmd cmd = case cmd of
-  CmdText opt -> runExtractText opt
-  CmdView fn mpw -> withFile fn $ runViewer fn mpw
-  CmdImage opt -> runExtractImages opt
-  CmdForm opt -> runExtractForm opt
-  CmdDiff opt -> runDiff opt
-  CmdInfo fn mpw -> withFile fn $ showInfo fn mpw
-  CmdTitle fn mpw -> withFile fn $ showTitle fn mpw
-  CmdToc fn mpw -> withFile fn $ showOutlines fn mpw
-  CmdTrailer fn -> withFile fn $ showTrailer fn
-  CmdObject rf fn mpw -> withFile fn $ showContent fn mpw rf
-  CmdRefs fn mpw -> withFile fn $ showRefs fn mpw
-  CmdGrep re fn mpw -> withFile fn $ grepPDF defaultLayoutOptions fn mpw re
-
-runExtractText :: ExtractOpt -> IO ()
-runExtractText ExtractOpt{eoPage=pg, eoGeom=gm, eoTagged=tg, eoLegacy=lg,
-                      eoQuiet=quiet, eoFootnotes=fnn, eoRuby=rb,
-                      eoPassword=pw, eoFile=fn} =
-  withFile fn $
-  let mpw = maybePassword pw
-      lopts = defaultLayoutOptions {optFootnotes = fnn, optRuby = rb}
-  in if pg /= 0
-     then showPage lopts fn mpw pg
-     else case () of
-       _ | lg && not gm && not tg -> do
-             doc <- runOrDie (openDocument fn mpw)
-             streamLegacyToStdout doc quiet
-         | gm && not tg && not lg -> pdfToTextGeom lopts fn mpw
-         | otherwise              -> pdfToTextTagged lopts fn mpw
-
-runExtractImages :: ImagesOpt -> IO ()
-runExtractImages ImagesOpt{ioPage=pg, ioOut=out, ioPassword=pw, ioFile=fn} =
-  withFile fn $
-  if pg < 1
-    then do
-      hPutStrLn stderr "hpdft: image requires -p PAGE (1-based)"
-      exitWith (ExitFailure 1)
-    else do
-      doc <- runOrDie (openDocument fn (maybePassword pw))
-      paths <- runOrDie (extractPageImagesToDir doc pg out)
-      if null paths
-        then hPutStrLn stderr ("hpdft: no image XObjects on page " ++ show pg)
-        else mapM_ putStrLn paths
-
-runExtractForm :: FormOpt -> IO ()
-runExtractForm FormOpt{foPage=pg, foName=mn, foOut=out, foPassword=pw, foFile=fn} =
-  withFile fn $
-  if pg < 1
-    then do
-      hPutStrLn stderr "hpdft: form requires -p PAGE (1-based)"
-      exitWith (ExitFailure 1)
-    else do
-      doc <- runOrDie (openDocument fn (maybePassword pw))
-      case mn of
-        Nothing -> do
-          names <- runOrDie (return (pageFormNames doc pg))
-          if null names
-            then hPutStrLn stderr ("hpdft: no Form XObjects on page " ++ show pg)
-            else mapM_ (putStrLn . T.unpack) names
-        Just nm -> do
-          path <- runOrDie (extractFormToFile doc pg (T.pack nm) out)
-          putStrLn path
-
-runDiff :: DiffOpt -> IO ()
-runDiff DiffOpt{doRuby=rb, doJson=json, doPassword=pw, doFileA=fa, doFileB=fb} =
-  withFile fa $
-  withFile fb $
-  let mpw = maybePassword pw
-      lopts = defaultLayoutOptions {optRuby = rb}
-  in do
-    docA <- runOrDie (openDocument fa mpw)
-    docB <- runOrDie (openDocument fb mpw)
-    changes <- runOrDie (return (compareDocuments lopts docA docB))
-    if json
-      then putStrLn (renderDiffJson changes)
-      else mapM_ putStrLn (renderDiffHuman changes)
-
-renderDiffHuman :: [TextChange] -> [String]
-renderDiffHuman = map renderOne
-  where
-    renderOne (PageCountMismatch pa pb) =
-      "page count mismatch: " ++ show pa ++ " vs " ++ show pb
-    renderOne TextChange{changePageA = pa, changePageB = pb,
-                         changeParaA = pxa, changeParaB = pxb,
-                         changeOld = old, changeNew = new} =
-      unlines
-        ( pageLine
-        : paraLine
-        : ("- old: " ++ T.unpack old) : ("+ new: " ++ T.unpack new) : []
-        )
-      where
-        pageLine =
-          case (pa, pb) of
-            (Just a, Just b) | a == b -> "page " ++ show a ++ ":"
-            (Just a, Just b) -> "page " ++ show a ++ " vs " ++ show b ++ ":"
-            (Just a, Nothing) -> "page " ++ show a ++ " (only in first file):"
-            (Nothing, Just b) -> "page " ++ show b ++ " (only in second file):"
-            _ -> "page ?:"
-        paraLine =
-          case (pxa, pxb) of
-            (Just a, Just b) | a == b -> "para " ++ show (a + 1) ++ ":"
-            (Just a, Just b) -> "para " ++ show (a + 1) ++ " vs " ++ show (b + 1) ++ ":"
-            (Just a, Nothing) -> "para " ++ show (a + 1) ++ ":"
-            (Nothing, Just b) -> "para " ++ show (b + 1) ++ ":"
-            _ -> "para ?:"
-
-renderDiffJson :: [TextChange] -> String
-renderDiffJson changes = "[" ++ intercalate "," (map encodeChange changes) ++ "]"
-  where
-    encodeChange (PageCountMismatch pa pb) =
-      "{\"type\":\"pageCountMismatch\",\"pagesA\":" ++ show pa
-        ++ ",\"pagesB\":" ++ show pb ++ "}"
-    encodeChange TextChange{changePageA = pa, changePageB = pb,
-                            changeParaA = pxa, changeParaB = pxb,
-                            changeOld = old, changeNew = new} =
-      "{\"type\":\"textChange\""
-        ++ maybeField "pageA" pa
-        ++ maybeField "pageB" pb
-        ++ maybeField "paraA" pxa
-        ++ maybeField "paraB" pxb
-        ++ ",\"old\":" ++ jsonString old
-        ++ ",\"new\":" ++ jsonString new
-        ++ "}"
-    maybeField _ Nothing = ""
-    maybeField k (Just v) = ",\"" ++ k ++ "\":" ++ show v
-    jsonString t =
-      "\"" ++ concatMap esc (T.unpack t) ++ "\""
-    esc '\\' = "\\\\"
-    esc '"' = "\\\""
-    esc '\n' = "\\n"
-    esc '\r' = "\\r"
-    esc '\t' = "\\t"
-    esc c = [c]
-
-describeError :: PdfError -> String
-describeError (ParseError msg _) = "parse error: " ++ msg
-describeError (BrokenXref msg) = "broken cross-reference: " ++ msg
-describeError (MissingObject n) = "missing object: " ++ show n ++ " 0 R"
-describeError (MissingKey key ctx) = "missing key " ++ key ++ " in " ++ ctx
-describeError (UnsupportedFeature msg) = "unsupported feature: " ++ msg
-describeError (DecryptionError msg) =
-  "cannot decrypt: " ++ msg ++ ". Use -P to supply a password."
-describeError (FontError n msg) = "font error in object " ++ show n ++ ": " ++ msg
-
-printWarnings :: [PdfWarning] -> IO ()
-printWarnings = mapM_ (hPutStrLn stderr . ("hpdft: warning: " ++) . renderPdfWarning)
-
-runOrDie :: IO (PdfResult a) -> IO a
-runOrDie action = do
-  result <- action
-  case result of
-    Right a -> return a
-    Left err -> do
-      hPutStrLn stderr ("hpdft: " ++ describeError err)
-      exitWith (ExitFailure 1)
-
-withFile :: FilePath -> IO () -> IO ()
-withFile fp action =
-  action `catch` \e -> do
-    if isDoesNotExistError (e :: IOException)
-      then do
-        hPutStrLn stderr ("hpdft: " ++ fp ++ ": does not exist")
-        exitWith (ExitFailure 1)
-      else ioError e
-
--- | Bare @hpdft FILE@: TUI preview on a TTY, legacy streaming on a pipe.
-runViewer :: FilePath -> Maybe String -> IO ()
-runViewer filename mpw = do
-  doc <- runOrDie (openDocument filename mpw)
-  stdoutTTY <- hIsTerminalDevice stdout
-  if stdoutTTY
-    then runTuiPreview filename doc
-    else streamLegacyToStdout doc False
-
-streamLegacyToStdout :: Document -> Bool -> IO ()
-streamLegacyToStdout doc quiet = do
-  stderrTTY <- hIsTerminalDevice stderr
-  let showProgress pg total =
-        when (not quiet && stderrTTY && total > 0) $
-          hPutStr stderr ("\rhpdft: page " ++ show pg ++ "/" ++ show total ++ "...")
-      clearProgress total =
-        when (not quiet && stderrTTY && total > 0) $
-          hPutStr stderr ("\r\ESC[K")
-  totalRef <- newIORef (0 :: Int)
-  ws <- pdfToTextStreamDoc doc $ \pg total txt -> do
-    writeIORef totalRef total
-    showProgress pg total
-    BSL.putStr txt
-    hFlush stdout
-  total <- readIORef totalRef
-  clearProgress total
-  -- Trailing newline for parity with the pre-0.4.6 batch output (putStrLn).
-  putStrLn ""
-  printWarnings ws
-
-pdfToTextGeom :: LayoutOptions -> FilePath -> Maybe String -> IO ()
-pdfToTextGeom lopts filename mpw = do
-  txt <- runOrDie (pdfToTextGeomBSWith lopts filename mpw)
-  BSL.putStrLn txt
-
-pdfToTextTagged :: LayoutOptions -> FilePath -> Maybe String -> IO ()
-pdfToTextTagged lopts filename mpw = do
-  txt <- runOrDie (pdfToTextTaggedBSWith lopts filename mpw)
-  BSL.putStrLn txt
-
-data PageTree = Nop | Page Int | Pages [PageTree]
-  deriving Show
-
-showRefs :: FilePath -> Maybe String -> IO ()
-showRefs filename mpw = do
-  doc <- runOrDie (openDocument filename mpw)
-  root <- runOrDie (return (docRootRef doc))
-  print $ pageTreeToList $ pageorder root (docObjs doc)
-
-refByPage :: FilePath -> Maybe String -> IO [Int]
-refByPage filename mpw = do
-  doc <- runOrDie (openDocument filename mpw)
-  root <- runOrDie (return (docRootRef doc))
-  return $ pageTreeToList $ pageorder root (docObjs doc)
-
-pageorder :: Int -> PDFObjIndex -> PageTree
-pageorder parent objs =
-  case findObjsByRef parent objs of
-    Just os -> case findDictOfType "/Catalog" os of
-      Just dict -> case findPages dict of
-        Just pr -> pageorder pr objs
-        Nothing -> Nop
-      Nothing -> case findDictOfType "/Pages" os of
-        Just dict -> case findKids dict of
-          Just kidsrefs -> Pages $ map (\f -> f objs) (map pageorder kidsrefs)
-          Nothing -> Nop
-        Nothing -> case findDictOfType "/Page" os of
-          Just dict -> Page parent
-          Nothing -> Nop
-    Nothing -> Nop
-
-pageTreeToList :: PageTree -> [Int]
-pageTreeToList (Pages ps) = concatMap pageTreeToList ps
-pageTreeToList (Page n) = [n]
-pageTreeToList Nop = []
-
-showPage :: LayoutOptions -> FilePath -> Maybe String -> Int -> IO ()
-showPage lopts filename mpw page = do
-  doc <- runOrDie (openDocument filename mpw)
-  root <- runOrDie (return (docRootRef doc))
-  let pagetree = pageTreeToList $ pageorder root (docObjs doc)
-  if page >= 1 && length pagetree >= page
-    then do
-      txt <- runOrDie (return (pageTextGeomWith lopts doc (pagetree !! (page - 1))))
-      BSL.putStr txt
-    else putStrLn $ "hpdft: No Page "++(show page)
-
-showContent :: FilePath -> Maybe String -> Int -> IO ()
-showContent filename mpw ref = do
-  doc <- runOrDie (openDocument filename mpw)
-  let objs = docObjs doc
-      sec = docSecurity doc
-  obj <- runOrDie (getObjectByRef ref objs)
-  if hasStream obj
-    then case findDict obj of
-      Just d | hasSubtype d -> printStreamWithDict sec ref d obj
-      _ -> do
-        strm <- runOrDie (getStream sec ref False obj)
-        BSL.putStrLn strm
-    else do
-      objs' <- runOrDie (getObjectByRef ref objs)
-      putStrLn $ "[" ++ intercalate ", " (map ppObj objs') ++ "]"
-  where
-
-    hasStream obj = case find isStream obj of
-      Just _ -> True
-      Nothing -> False
-    isStream (PdfStream _) = True
-    isStream _             = False
-
-    hasSubtype d = case findObjFromDict d "/Subtype" of
-      Just _ -> True
-      Nothing -> False
-
-    printStreamWithDict :: Maybe Security -> Int -> Dict -> [Obj] -> IO ()
-    printStreamWithDict sec' ref' d obj = do
-      putStrLn $ ppObj (PdfDict d)
-      strm <- runOrDie (getStream sec' ref' True obj)
-      BSL.putStrLn strm
-
-showTitle :: FilePath -> Maybe String -> IO ()
-showTitle filename mpw = do
-  doc <- runOrDie (openDocument filename mpw)
-  d <- runOrDie (return (docInfoDict doc))
-  let title =
-        case findObjFromDict d "/Title" of
-          Just (PdfText s) -> T.unpack s
-          Just x -> ppObj x
-          Nothing -> "No title anyway"
-  putStrLn title
-
-showInfo :: FilePath -> Maybe String -> IO ()
-showInfo filename mpw = do
-  doc <- runOrDie (openDocument filename mpw)
-  d <- runOrDie (return (docInfoDict doc))
-  putStrLn $ ppObj (PdfDict d)
-
-showOutlines :: FilePath -> Maybe String -> IO ()
-showOutlines filename mpw = do
-  d <- runOrDie (getOutlines filename mpw)
-  putStrLn $ show d
-
-showTrailer :: FilePath -> IO ()
-showTrailer filename = do
-  doc <- runOrDie (openDocument filename Nothing)
-  putStrLn $ ppDictEntries (docTrailer doc)
-
-grepPDF :: LayoutOptions -> FilePath -> Maybe String -> String -> IO ()
-grepPDF lopts filename mpw re = do
-  doc <- runOrDie (openDocument filename mpw)
-  root <- runOrDie (return (docRootRef doc))
-  let objs = docObjs doc
-  mapM_
-    (\(ref, pagenm) -> grepByPage pagenm re (pageText doc ref))
-    $ zip (pageTreeToList $ pageorder root objs) [1..]
-
-  where
-    pageText doc ref =
-      case pageTextGeomWith lopts doc ref of
-        Right txt -> txt
-        Left _ -> ""
-
-    grepByPage :: Int -> String -> BSL.ByteString -> IO ()
-    grepByPage pagenm re txt = do
-      let matched = filter (not . null) $ map (grepByLine re) $ BSL.lines txt
-      when (not $ null matched) (showResult pagenm matched)
-      return ()
-      where
-        showResult p m = do
-          putStrLn $ "At page " <> show p <> "..."
-          mapM (putStrLn . (" | " <>)) m
-          return ()
-
-    grepByLine :: String -> PDFStream -> String
-    grepByLine re txt =
-      case regexec (makeRegex re) $ TL.unpack $ TL.decodeUtf8 txt of
-        Left _  -> ""
-        Right m -> case m of
-         Just (b, m, a, _) -> (b <> (highlight m) <> a)
-         Nothing           -> ""
-
-    highlight m = "\ESC[31m" <> m <> "\ESC[0m"
diff --git a/src/PDF/ContentStream.hs b/src/PDF/ContentStream.hs
--- a/src/PDF/ContentStream.hs
+++ b/src/PDF/ContentStream.hs
@@ -3,12 +3,17 @@
 module PDF.ContentStream 
        ( parseStream
        , parseColorSpace
-       , normalizePdfNumber
-       , parsePdfNumber
        ) where
 
+import PDF.StreamLex
+  ( hexPairs
+  , parsePdfNumber
+  , sjisBytesToCodes
+  , unicodeBytesToCodes
+  , jisBytesToCodes
+  )
+
 import Data.Char (chr, ord)
-import Data.Bits (shiftL)
 import Data.String (fromString)
 import Data.List (isPrefixOf, dropWhileEnd)
 import Numeric (readOct, readHex)
@@ -374,7 +379,7 @@
   in case enc of
     Just SJISmap -> sjisBytesToCodes bytes
     Just UnicodeMap -> unicodeBytesToCodes bytes
-    Just JISmap -> pairBytes bytes
+    Just JISmap -> jisBytesToCodes bytes
     Just (CIDmap _) -> pairBytes bytes
     _ -> bytes
   where
@@ -382,41 +387,6 @@
     pairBytes [_] = []
     pairBytes (a:b:rest) = (a * 256 + b) : pairBytes rest
 
-isUtf16HighSurrogate :: Int -> Bool
-isUtf16HighSurrogate u = u >= 0xD800 && u <= 0xDBFF
-
-isUtf16LowSurrogate :: Int -> Bool
-isUtf16LowSurrogate u = u >= 0xDC00 && u <= 0xDFFF
-
-surrogatePairToCode :: Int -> Int -> Int
-surrogatePairToCode hi lo = 0x10000 + ((hi - 0xD800) `shiftL` 10) + (lo - 0xDC00)
-
-unicodeBytesToCodes :: [Int] -> [Int]
-unicodeBytesToCodes [] = []
-unicodeBytesToCodes [_] = []
-unicodeBytesToCodes (a:b:rest) =
-  let unit = a * 256 + b
-  in if isUtf16HighSurrogate unit
-     then case rest of
-       (c:d:rs) ->
-         let unit2 = c * 256 + d
-         in if isUtf16LowSurrogate unit2
-            then surrogatePairToCode unit unit2 : unicodeBytesToCodes rs
-            else unit : unicodeBytesToCodes rest
-       _ -> [unit]
-     else unit : unicodeBytesToCodes rest
-
-isSjisLead :: Int -> Bool
-isSjisLead b = (b >= 0x81 && b <= 0x9F) || (b >= 0xE0 && b <= 0xFC)
-
-sjisBytesToCodes :: [Int] -> [Int]
-sjisBytesToCodes [] = []
-sjisBytesToCodes (b:rest)
-  | isSjisLead b = case rest of
-      (t:rs) -> (b * 256 + t) : sjisBytesToCodes rs
-      _ -> [b]
-  | otherwise = b : sjisBytesToCodes rest
-
 sjisCodeToText :: Int -> T.Text
 sjisCodeToText code =
   case Map.lookup code cp932Map of
@@ -486,24 +456,9 @@
                          , try $ chr <$> ((string "\\") *> octnum)
                          , try $ noneOf ")"
                          ])
-  let codes = pairBytes $ map ord txt
+  let codes = jisBytesToCodes $ map ord txt
   return $ T.concat $ map jisCodeToText codes
-  where
-    pairBytes [] = []
-    pairBytes [_] = []
-    pairBytes (a:b:rest) = (a * 256 + b) : pairBytes rest
 
-hexPairs :: String -> [Int]
-hexPairs [] = []
-hexPairs [x] =
-  case readHex [x, '0'] of
-    [(n, "")] -> [n]
-    _         -> []
-hexPairs (a:b:rest) =
-  case readHex [a, b] of
-    [(n, "")] -> n : hexPairs rest
-    _         -> hexPairs rest
-
 octletters :: PSParser T.Text
 octletters = do
   char '('
@@ -868,25 +823,6 @@
          <|>
          ((++) <$> (many1 digit) <*> ((++) <$> (many $ char '.') <*> many digit))
   return $ parsePdfNumber $ sign ++ num
-
-normalizePdfNumber :: String -> String
-normalizePdfNumber s
-  | null s = s
-  | head s == '.' = '0' : s
-  | length s >= 2 && head s == '-' && s !! 1 == '.' = '-' : '0' : drop 1 s
-  | otherwise = s
-
-parsePdfNumber :: String -> Double
-parsePdfNumber s
-  | null s || s == "-" || s == "+" = 0
-  | last s == '.' =
-      case reads (normalizePdfNumber s ++ "0") of
-        [(n, "")] -> n
-        _         -> 0
-  | otherwise =
-      case reads (normalizePdfNumber s) of
-        [(n, "")] -> n
-        _         -> 0
 
 hexParam :: Parser T.Text
 hexParam = do
diff --git a/src/PDF/DocumentStructure.hs b/src/PDF/DocumentStructure.hs
--- a/src/PDF/DocumentStructure.hs
+++ b/src/PDF/DocumentStructure.hs
@@ -740,19 +740,18 @@
   case parseObjStmHeader mFirst s of
     Right (location, body) ->
       Right [ (r, parseObjStmObject body o) | (r, o) <- location ]
-    Left err ->
-      Left (ParseError ("Failed to parse Object Stream: " ++ show err) (BS.take 80 s))
+    Left err -> Left err
 
-parseObjStmHeader :: Maybe Int -> BS.ByteString -> Either String ([(Int, Int)], BS.ByteString)
+parseObjStmHeader :: Maybe Int -> BS.ByteString -> PdfResult ([(Int, Int)], BS.ByteString)
 parseObjStmHeader (Just first) s
   | first >= 0 && first <= BS.length s =
       case parseOnly refPairs (BS.take first s) of
         Right location -> Right (location, BS.drop first s)
-        Left err -> Left (show err)
+        Left err -> Left (ParseError ("Failed to parse Object Stream header: " ++ show err) (BS.take 80 s))
 parseObjStmHeader _ s =
   case parseOnly refOffset s of
     Right (location, body) -> Right (location, body)
-    Left err -> Left (show err)
+    Left err -> Left (ParseError ("Failed to parse Object Stream header: " ++ show err) (BS.take 80 s))
 
 parseObjStmObject :: BS.ByteString -> Int -> [Obj]
 parseObjStmObject body off =
@@ -760,14 +759,14 @@
     Right obj -> obj
     Left _ -> [PdfNull]
 
-parseObjStmValue :: BS.ByteString -> Either String [Obj]
+parseObjStmValue :: BS.ByteString -> PdfResult [Obj]
 parseObjStmValue s' = case parseOnly pdfdictionary s' of
   Right obj -> Right [obj]
   Left _ -> case parseOnly pdfarray s' of
     Right obj -> Right [obj]
     Left _ -> case parseOnly pdfletters s' of
       Right obj -> Right [obj]
-      Left err -> Left (show err)
+      Left err -> Left (ParseError ("Failed to parse Object Stream value: " ++ show err) (BS.take 80 s'))
 
 
 -- make fontmap from page's /Resources (see 3.7.2 of PDF Ref.)
diff --git a/src/PDF/Encrypt.hs b/src/PDF/Encrypt.hs
--- a/src/PDF/Encrypt.hs
+++ b/src/PDF/Encrypt.hs
@@ -6,6 +6,7 @@
   , securityFromEncryptDict
   , decryptString
   , decryptStream
+  , rc4KeyStream
   ) where
 
 import PDF.Definition
@@ -17,6 +18,7 @@
 import Data.List (foldl')
 import qualified Data.Map as M
 import Control.Applicative ((<|>))
+import Data.Maybe (mapMaybe)
 import Control.Monad (forM_, forM)
 import Control.Monad.ST (ST, runST)
 import Data.Array.ST (STUArray, newArray, readArray, writeArray)
@@ -143,10 +145,22 @@
 hexToBytes :: T.Text -> Maybe BS.ByteString
 hexToBytes h
   | T.null h = Just BS.empty
-  | otherwise = Just $ BS.pack $ map (fromIntegral . fst . head . readHex) (pairs (T.unpack h))
+  | otherwise =
+      let chunks = pairs (T.unpack h)
+          bytes = mapMaybe parseHexPair chunks
+      in if length bytes == length chunks then Just (BS.pack bytes) else Nothing
   where
     pairs [] = []
     pairs s  = take 2 s : pairs (drop 2 s)
+    parseHexPair [a, b] =
+      case readHex [a, b] of
+        [(n, "")] -> Just (fromIntegral n)
+        _         -> Nothing
+    parseHexPair [a] =
+      case readHex [a, '0'] of
+        [(n, "")] -> Just (fromIntegral n)
+        _         -> Nothing
+    parseHexPair _ = Nothing
 
 md5 :: BS.ByteString -> BS.ByteString
 md5 bs = convert (hash bs :: Digest MD5)
diff --git a/src/PDF/Error.hs b/src/PDF/Error.hs
--- a/src/PDF/Error.hs
+++ b/src/PDF/Error.hs
@@ -35,6 +35,7 @@
   , PdfResult
   , PdfWarning(..)
   , renderPdfWarning
+  , renderPdfError
   , note
   ) where
 
@@ -73,6 +74,8 @@
     -- ^ Fallback encoding used for a font.
   | UnmappedCid Int
     -- ^ CID not found in Adobe-Japan1-6; bracket placeholder emitted.
+  | PageContentFailed Int String
+    -- ^ Legacy content-stream extraction failed for a page object.
   deriving (Show, Eq)
 
 renderPdfWarning :: PdfWarning -> String
@@ -83,6 +86,17 @@
   "font object " ++ show n ++ ": using fallback encoding " ++ enc
 renderPdfWarning (UnmappedCid cid) =
   "unmapped CID " ++ show cid ++ " (Adobe-Japan1-6)"
+renderPdfWarning (PageContentFailed ref reason) =
+  "page object " ++ show ref ++ ": content extraction failed: " ++ reason
+
+renderPdfError :: PdfError -> String
+renderPdfError (ParseError msg _) = "parse error: " ++ msg
+renderPdfError (BrokenXref msg) = "broken cross-reference: " ++ msg
+renderPdfError (MissingObject n) = "missing object: " ++ show n ++ " 0 R"
+renderPdfError (MissingKey key ctx) = "missing key " ++ key ++ " in " ++ ctx
+renderPdfError (UnsupportedFeature msg) = "unsupported feature: " ++ msg
+renderPdfError (DecryptionError msg) = "cannot decrypt: " ++ msg
+renderPdfError (FontError n msg) = "font error in object " ++ show n ++ ": " ++ msg
 
 -- | Annotate a 'Maybe' with an error.
 note :: PdfError -> Maybe a -> PdfResult a
diff --git a/src/PDF/Interpret.hs b/src/PDF/Interpret.hs
--- a/src/PDF/Interpret.hs
+++ b/src/PDF/Interpret.hs
@@ -16,12 +16,16 @@
   , interpretContentWithFontsItems
   , bytesToCodes
   , encodingUnicode
-  , unicodeBytesToCodes
-  , normalizePdfNumber
-  , parsePdfNumber
   ) where
 
 import PDF.Definition
+import PDF.StreamLex
+  ( hexPairs
+  , jisBytesToCodes
+  , parsePdfNumberFromByteString
+  , sjisBytesToCodes
+  , unicodeBytesToCodes
+  )
 import PDF.Document (Document(..))
 import PDF.DocumentStructure
   ( findDictByRef
@@ -694,46 +698,6 @@
     pairs [_] = []
     pairs (a:b:rest) = (a * 256 + b) : pairs rest
 
-isUtf16HighSurrogate :: Int -> Bool
-isUtf16HighSurrogate u = u >= 0xD800 && u <= 0xDBFF
-
-isUtf16LowSurrogate :: Int -> Bool
-isUtf16LowSurrogate u = u >= 0xDC00 && u <= 0xDFFF
-
-surrogatePairToCode :: Int -> Int -> Int
-surrogatePairToCode hi lo = 0x10000 + ((hi - 0xD800) `shiftL` 10) + (lo - 0xDC00)
-
-unicodeBytesToCodes :: [Int] -> [Int]
-unicodeBytesToCodes [] = []
-unicodeBytesToCodes [_] = []
-unicodeBytesToCodes (a:b:rest) =
-  let unit = a * 256 + b
-  in if isUtf16HighSurrogate unit
-     then case rest of
-       (c:d:rs) ->
-         let unit2 = c * 256 + d
-         in if isUtf16LowSurrogate unit2
-            then surrogatePairToCode unit unit2 : unicodeBytesToCodes rs
-            else unit : unicodeBytesToCodes rest
-       _ -> [unit]
-     else unit : unicodeBytesToCodes rest
-
-jisBytesToCodes :: [Int] -> [Int]
-jisBytesToCodes [] = []
-jisBytesToCodes [_] = []
-jisBytesToCodes (a:b:rest) = (a * 256 + b) : jisBytesToCodes rest
-
-isSjisLead :: Int -> Bool
-isSjisLead b = (b >= 0x81 && b <= 0x9F) || (b >= 0xE0 && b <= 0xFC)
-
-sjisBytesToCodes :: [Int] -> [Int]
-sjisBytesToCodes [] = []
-sjisBytesToCodes (b:rest)
-  | isSjisLead b = case rest of
-      (t:rs) -> (b * 256 + t) : sjisBytesToCodes rs
-      _ -> [b]
-  | otherwise = b : sjisBytesToCodes rest
-
 codeToUnicode :: FontInfo -> Int -> T.Text
 codeToUnicode fi code =
   case M.lookup code (fiToUnicode fi) of
@@ -912,17 +876,6 @@
     elemObj o = TJString <$> objBytes o
 tjElems _ = Nothing
 
-hexPairs :: String -> [Int]
-hexPairs [] = []
-hexPairs [x] =
-  case Num.readHex [x, '0'] of
-    [(n, "")] -> [n]
-    _         -> []
-hexPairs (a:b:rest) =
-  case Num.readHex [a, b] of
-    [(n, "")] -> n : hexPairs rest
-    _         -> hexPairs rest
-
 w2c :: Word8 -> Char
 w2c = chr . fromIntegral
 
@@ -988,7 +941,7 @@
   let (numBs, rest) = spanNum8 bs
   in if BSL.null numBs
      then Nothing
-     else Just (TokOperand (PdfNumber (parsePdfNumber (map w2c (BSL.unpack numBs)))), rest)
+     else Just (TokOperand (PdfNumber (parsePdfNumberFromByteString numBs)), rest)
 
 spanNum8 :: BSL.ByteString -> (BSL.ByteString, BSL.ByteString)
 spanNum8 bs =
@@ -1020,25 +973,6 @@
   || (c >= 'a' && c <= 'z')
   || (c >= '0' && c <= '9')
   || c == '*'
-
-normalizePdfNumber :: String -> String
-normalizePdfNumber s
-  | null s = s
-  | head s == '.' = '0' : s
-  | length s >= 2 && head s == '-' && s !! 1 == '.' = '-' : '0' : drop 1 s
-  | otherwise = s
-
-parsePdfNumber :: String -> Double
-parsePdfNumber s
-  | null s || s == "-" || s == "+" = 0
-  | last s == '.' =
-      case reads (normalizePdfNumber s ++ "0") of
-        [(n, "")] -> n
-        _         -> 0
-  | otherwise =
-      case reads (normalizePdfNumber s) of
-        [(n, "")] -> n
-        _         -> 0
 
 readName :: BSL.ByteString -> Maybe (Token, BSL.ByteString)
 readName bs =
diff --git a/src/PDF/Page.hs b/src/PDF/Page.hs
--- a/src/PDF/Page.hs
+++ b/src/PDF/Page.hs
@@ -27,12 +27,14 @@
   , pageCount
   , pageRefAt
   , pageRefs
+  , pageRefsFromRoot
   , pageItems
   , pageGlyphs
   , pageLines
   , pageParagraphs
   , PageRegion(..)
   , pageRegions
+  , Rect(..)
   ) where
 
 import PDF.Definition (PDFObjIndex)
diff --git a/src/PDF/StreamLex.hs b/src/PDF/StreamLex.hs
new file mode 100644
--- /dev/null
+++ b/src/PDF/StreamLex.hs
@@ -0,0 +1,94 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module PDF.StreamLex
+  ( normalizePdfNumber
+  , parsePdfNumber
+  , parsePdfNumberFromByteString
+  , hexPairs
+  , isUtf16HighSurrogate
+  , isUtf16LowSurrogate
+  , surrogatePairToCode
+  , unicodeBytesToCodes
+  , isSjisLead
+  , sjisBytesToCodes
+  , jisBytesToCodes
+  ) where
+
+import Data.Bits (shiftL)
+import Data.Char (chr)
+import qualified Data.ByteString.Lazy as BSL
+import Numeric (readHex)
+
+normalizePdfNumber :: String -> String
+normalizePdfNumber s
+  | null s = s
+  | head s == '.' = '0' : s
+  | length s >= 2 && head s == '-' && s !! 1 == '.' = '-' : '0' : drop 1 s
+  | otherwise = s
+
+parsePdfNumber :: String -> Double
+parsePdfNumber s
+  | null s || s == "-" || s == "+" = 0
+  | last s == '.' =
+      case reads (normalizePdfNumber s ++ "0") of
+        [(n, "")] -> n
+        _         -> 0
+  | otherwise =
+      case reads (normalizePdfNumber s) of
+        [(n, "")] -> n
+        _         -> 0
+
+parsePdfNumberFromByteString :: BSL.ByteString -> Double
+parsePdfNumberFromByteString bs =
+  parsePdfNumber (map (chr . fromEnum) (BSL.unpack bs))
+
+hexPairs :: String -> [Int]
+hexPairs [] = []
+hexPairs [x] =
+  case readHex [x, '0'] of
+    [(n, "")] -> [n]
+    _         -> []
+hexPairs (a:b:rest) =
+  case readHex [a, b] of
+    [(n, "")] -> n : hexPairs rest
+    _         -> hexPairs rest
+
+isUtf16HighSurrogate :: Int -> Bool
+isUtf16HighSurrogate u = u >= 0xD800 && u <= 0xDBFF
+
+isUtf16LowSurrogate :: Int -> Bool
+isUtf16LowSurrogate u = u >= 0xDC00 && u <= 0xDFFF
+
+surrogatePairToCode :: Int -> Int -> Int
+surrogatePairToCode hi lo = 0x10000 + ((hi - 0xD800) `shiftL` 10) + (lo - 0xDC00)
+
+unicodeBytesToCodes :: [Int] -> [Int]
+unicodeBytesToCodes [] = []
+unicodeBytesToCodes [_] = []
+unicodeBytesToCodes (a:b:rest) =
+  let unit = a * 256 + b
+  in if isUtf16HighSurrogate unit
+     then case rest of
+       (c:d:rs) ->
+         let unit2 = c * 256 + d
+         in if isUtf16LowSurrogate unit2
+            then surrogatePairToCode unit unit2 : unicodeBytesToCodes rs
+            else unit : unicodeBytesToCodes rest
+       _ -> [unit]
+     else unit : unicodeBytesToCodes rest
+
+isSjisLead :: Int -> Bool
+isSjisLead b = (b >= 0x81 && b <= 0x9F) || (b >= 0xE0 && b <= 0xFC)
+
+sjisBytesToCodes :: [Int] -> [Int]
+sjisBytesToCodes [] = []
+sjisBytesToCodes (b:rest)
+  | isSjisLead b = case rest of
+      (t:rs) -> (b * 256 + t) : sjisBytesToCodes rs
+      _ -> [b]
+  | otherwise = b : sjisBytesToCodes rest
+
+jisBytesToCodes :: [Int] -> [Int]
+jisBytesToCodes [] = []
+jisBytesToCodes [_] = []
+jisBytesToCodes (a:b:rest) = (a * 256 + b) : jisBytesToCodes rest
diff --git a/src/PDF/Text.hs b/src/PDF/Text.hs
--- a/src/PDF/Text.hs
+++ b/src/PDF/Text.hs
@@ -51,13 +51,13 @@
   ) where
 
 import PDF.Definition
-import PDF.Error (PdfResult, PdfWarning(..))
+import PDF.Error (PdfResult, PdfWarning(..), renderPdfError)
 import PDF.Document (Document(..), openDocument, docRootRef)
 import PDF.DocumentStructure
 import PDF.Encrypt (Security)
 import PDF.Interpret (Glyph(..), Rect(..), PageItem(..), interpretPageItems)
 import PDF.Layout (LayoutOptions(..), defaultLayoutOptions, aozoraRuby, joinGlyphsRun, layoutDocumentFromPageLines, layoutPageTextWith, linesFromGlyphs, pageLinesRaw, stripHeadersFooters, joinParaLines, forcePageLines)
-import PDF.Page (pageRefs)
+import PDF.Page (pageRefs, pageRefsFromRoot)
 import PDF.Structure (StructElem(..), RubySpan(..), structTree, logicalOrder, collectRubySpans)
 
 import Control.DeepSeq (force)
@@ -133,11 +133,11 @@
         Just os ->
           case findDictOfType "/Page" os of
             Just dict -> do
-              let (txt, ws) = pageContent dict initstate sec objs
+              let (txt, ws) = pageContent ref dict initstate sec objs
               callback num total txt
               return ws
-            Nothing -> return []
-        Nothing -> return []
+            Nothing -> return [PageContentFailed ref "not a page dictionary"]
+        Nothing -> return [PageContentFailed ref "missing page object"]
 
 pdfToTextGeomBS :: FilePath -> Maybe String -> IO (PdfResult BSL.ByteString)
 pdfToTextGeomBS = pdfToTextGeomBSWith defaultLayoutOptions
@@ -341,27 +341,23 @@
 artifactGlyphs items = [g | ItemGlyph g <- items, glyphMCID g == Nothing]
 
 walkdown :: PSR -> Int -> Maybe Security -> PDFObjIndex -> (BSL.ByteString, [PdfWarning])
-walkdown st parent sec objs =
-  case findObjsByRef parent objs of
-    Just os -> case findDictOfType "/Catalog" os of
-      Just dict -> case findPages dict of
-        Just pr -> walkdown st pr sec objs
-        Nothing -> ("", [])
-      Nothing -> case findDictOfType "/Pages" os of
-        Just dict -> case findKids dict of
-          Just kidsrefs ->
-            let results = map (\k -> walkdown st k sec objs) kidsrefs
-            in ( BSL.concat (map fst results)
-               , concatMap snd results
-               )
-          Nothing -> ("", [])
-        Nothing -> case findDictOfType "/Page" os of
-          Just dict -> pageContent dict st sec objs
-          Nothing -> ("", [])
-    Nothing -> ("", [])
+walkdown st rootref sec objs =
+  let refs = pageRefsFromRoot rootref objs
+      results = map (pageContentRef st sec objs) refs
+  in ( BSL.concat (map fst results)
+     , concatMap snd results
+     )
 
-pageContent :: Dict -> PSR -> Maybe Security -> PDFObjIndex -> (BSL.ByteString, [PdfWarning])
-pageContent dict st sec objs =
+pageContentRef :: PSR -> Maybe Security -> PDFObjIndex -> Int -> (BSL.ByteString, [PdfWarning])
+pageContentRef st sec objs ref =
+  case findObjsByRef ref objs of
+    Just os -> case findDictOfType "/Page" os of
+      Just dict -> pageContent ref dict st sec objs
+      Nothing -> ("", [PageContentFailed ref "not a page dictionary"])
+    Nothing -> ("", [PageContentFailed ref "missing page object"])
+
+pageContent :: Int -> Dict -> PSR -> Maybe Security -> PDFObjIndex -> (BSL.ByteString, [PdfWarning])
+pageContent ref dict st sec objs =
   case contentsStream dict st sec objs of
     Right (s, ws) -> (s, ws)
-    Left _ -> ("", [])
+    Left err -> ("", [PageContentFailed ref (renderPdfError err)])
diff --git a/test/EncryptSpec.hs b/test/EncryptSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/EncryptSpec.hs
@@ -0,0 +1,26 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module EncryptSpec (encryptSpecCases) where
+
+import PDF.Encrypt (rc4KeyStream)
+
+import Data.Word (Word8)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BSC
+
+encryptSpecCases :: [(String, BS.ByteString, BS.ByteString)]
+encryptSpecCases =
+  let keyStream = rc4KeyStream (BSC.pack "Key") 10
+   in [ ( "rc4KeyStream Key deterministic"
+        , keyStream
+        , keyStream
+        )
+      , ( "rc4KeyStream empty for zero length"
+        , BS.empty
+        , rc4KeyStream (BSC.pack "Key") 0
+        )
+      , ( "rc4KeyStream five-byte key prefix"
+        , BS.pack ([0xB2, 0x39, 0x63, 0x05, 0xF0] :: [Word8])
+        , rc4KeyStream (BS.pack [1, 2, 3, 4, 5]) 5
+        )
+      ]
diff --git a/test/Golden.hs b/test/Golden.hs
--- a/test/Golden.hs
+++ b/test/Golden.hs
@@ -1,6 +1,8 @@
 {-# LANGUAGE OverloadedStrings #-}
 
-import PDF.Text (pdfToTextBS, pdfToTextTaggedBS)
+import PDF.Text (pdfToTextBS, pdfToTextTaggedBS, pdfToTextGeomBSWith)
+import PDF.Layout (defaultLayoutOptions)
+import PDF.Error (PdfError)
 
 import qualified Data.ByteString.Lazy as BSL
 import qualified Data.ByteString.Lazy.Char8 as BSLC
@@ -10,10 +12,11 @@
 import System.FilePath ((</>), takeBaseName)
 import Control.Monad (when)
 
-fixturesDir, expectedDir, expectedLegacyDir, knownFailingDir :: FilePath
+fixturesDir, expectedDir, expectedLegacyDir, expectedGeomDir, knownFailingDir :: FilePath
 fixturesDir = "data/fixtures"
 expectedDir = fixturesDir </> "expected"
 expectedLegacyDir = fixturesDir </> "expected-legacy"
+expectedGeomDir = fixturesDir </> "expected-geom"
 knownFailingDir = fixturesDir </> "known-failing"
 
 main :: IO ()
@@ -22,12 +25,16 @@
   let pdfs = map (fixturesDir </>) (sort entries)
   failsDefault <- concat <$> mapM (checkFixture "default" expectedDir pdfToTextTaggedBS) pdfs
   failsLegacy <- concat <$> mapM (checkFixture "legacy" expectedLegacyDir pdfToTextBS) pdfs
-  let fails = failsDefault ++ failsLegacy
+  failsGeom <- concat <$> mapM (checkFixture "geom" expectedGeomDir geomExtract) pdfs
+  let fails = failsDefault ++ failsLegacy ++ failsGeom
   known <- listKnownFailing
   mapM_ (putStrLn . ("SKIP (known-failing): " ++)) known
   when (not (null fails)) $ do
     mapM_ putStrLn fails
     exitFailure
+
+geomExtract :: FilePath -> Maybe String -> IO (Either PdfError BSL.ByteString)
+geomExtract path mpw = pdfToTextGeomBSWith defaultLayoutOptions path mpw
 
 listKnownFailing :: IO [FilePath]
 listKnownFailing = do
diff --git a/test/Unit.hs b/test/Unit.hs
--- a/test/Unit.hs
+++ b/test/Unit.hs
@@ -1,19 +1,12 @@
 {-# LANGUAGE OverloadedStrings #-}
 import PDF.Matrix (Matrix, identity, multiply, apply, applyVec, translate, scale, mkMatrix, components)
 import PDF.Definition (Obj(..), FontInfo(..), Encoding(..))
-import PDF.Interpret
-  ( Glyph(..)
-  , Rect(..)
-  , PageItem(..)
-  , interpretContentWithFonts
-  , interpretContentWithFontsItems
-  , interpretPageImageHits
-  , bytesToCodes
-  , encodingUnicode
+import PDF.StreamLex
+  ( normalizePdfNumber
+  , parsePdfNumber
   , unicodeBytesToCodes
   )
-import qualified PDF.Interpret as Interpret
-import qualified PDF.ContentStream as ContentStream
+import PDF.StreamLex
 import PDF.Layout (LayoutOptions(..), defaultLayoutOptions, needsAozoraBar, aozoraRuby, layoutParagraphs, layoutParagraphsWith, layoutPageText, layoutDocument, sortLinesByReadingOrder, linesFromGlyphs, Line(..))
 import PDF.Structure (StructElem(..), StructKid(..), structTree, logicalOrder)
 import PDF.Document (Document(..), openDocument)
@@ -45,6 +38,18 @@
 import System.FilePath ((</>))
 import Control.Monad (when)
 import Data.IORef (newIORef, readIORef, modifyIORef)
+import PDF.Interpret
+  ( Glyph(..)
+  , Rect(..)
+  , PageItem(..)
+  , interpretContentWithFonts
+  , interpretContentWithFontsItems
+  , interpretPageImageHits
+  , bytesToCodes
+  , encodingUnicode
+  )
+import EncryptSpec (encryptSpecCases)
+import TuiGeometry (HeightSpec(..), parseHeightSpec, viewportHeight)
 import TuiScroll
   ( ScrollState(..)
   , initialScrollState
@@ -120,6 +125,12 @@
     then pass label
     else testFail label ("expected " ++ show expected ++ ", got " ++ show actual)
 
+assertBytesEq :: String -> BS.ByteString -> BS.ByteString -> Result
+assertBytesEq label expected actual =
+  if expected == actual
+    then pass label
+    else testFail label ("expected " ++ show expected ++ ", got " ++ show actual)
+
 assertGlyphOrigin :: String -> Double -> Double -> Glyph -> Result
 assertGlyphOrigin label x y g =
   if approxEq x (glyphX g) && approxEq y (glyphY g)
@@ -190,6 +201,8 @@
           ++ textStreamResults
           ++ cmapEncodingResults
           ++ normalizePdfNumberResults
+          ++ heightSpecResults
+          ++ encryptSpecResults
           ++ tuiScrollResults
   let failures = [msg | Fail msg <- results]
       passed = length results - length failures
@@ -1398,24 +1411,35 @@
         , "."
         , "-."
         ]
-   in [ assertDoubleEq ("Interpret.parsePdfNumber " ++ s) n (Interpret.parsePdfNumber s)
+   in [ assertDoubleEq ("StreamLex.parsePdfNumber " ++ s) n (parsePdfNumber s)
       | (s, n) <- cases
       ]
-      ++ [ assertDoubleEq ("ContentStream.parsePdfNumber " ++ s) n (ContentStream.parsePdfNumber s)
-         | (s, n) <- cases
-         ]
-      ++ [ assertBool ("Interpret.parsePdfNumber abnormal " ++ show s)
-             (Interpret.parsePdfNumber s `seq` True)
-         | s <- abnormal
-         ]
-      ++ [ assertBool ("ContentStream.parsePdfNumber abnormal " ++ show s)
-             (ContentStream.parsePdfNumber s `seq` True)
+      ++ [ assertBool ("StreamLex.parsePdfNumber abnormal " ++ show s)
+             (parsePdfNumber s `seq` True)
          | s <- abnormal
          ]
-      ++ [ assertTextEq ("Interpret.normalizePdfNumber " ++ s) (T.pack expected) (T.pack (Interpret.normalizePdfNumber s))
-         | (s, expected) <- [(".5", "0.5"), ("-.5", "-0.5"), ("-.23999999", "-0.23999999")]
-         ]
-      ++ [ assertTextEq ("ContentStream.normalizePdfNumber " ++ s) (T.pack expected) (T.pack (ContentStream.normalizePdfNumber s))
+      ++ [ assertTextEq ("StreamLex.normalizePdfNumber " ++ s) (T.pack expected) (T.pack (normalizePdfNumber s))
          | (s, expected) <- [(".5", "0.5"), ("-.5", "-0.5"), ("-.23999999", "-0.23999999")]
          ]
+
+encryptSpecResults :: [Result]
+encryptSpecResults =
+  [ assertBytesEq label expected actual | (label, expected, actual) <- encryptSpecCases ]
+
+heightSpecResults :: [Result]
+heightSpecResults =
+  [ assertBool "parseHeightSpec rows" (parseHeightSpec "12" == Just (HeightRows 12))
+  , assertBool "parseHeightSpec percent" (parseHeightSpec "50%" == Just (HeightPercent 50))
+  , assertBool "parseHeightSpec rejects bad percent"
+      (parseHeightSpec "150%" == Nothing)
+  , assertBool "viewportHeight default half" (viewportHeight 24 Nothing == 12)
+  , assertBool "viewportHeight rows clamp min"
+      (viewportHeight 24 (Just (HeightRows 3)) == 6)
+  , assertBool "viewportHeight rows clamp max"
+      (viewportHeight 24 (Just (HeightRows 40)) == 24)
+  , assertBool "viewportHeight percent"
+      (viewportHeight 20 (Just (HeightPercent 50)) == 10)
+  , assertBool "viewportHeight percent full"
+      (viewportHeight 10 (Just (HeightPercent 100)) == 10)
+  ]
 
