refactor: streaming loader, hierarchical TOC, parser robustness

Rework the reader around a clean BookInfo interface and lazy chapter loading.

Types/parser:
- BookInfo now splits into loadStructure (cheap: spine refs + TOC tree) and renderChapter (one chapter on demand). EpubEnv carries the parsed archive.
- resolveZipPath collapses ./.. and strips #fragments (normalise does not), so relative hrefs like ../Images/x.jpg resolve; used for chapters and images.
- decodeEntry tolerates encodings: UTF-8/UTF-16 BOMs, Latin-1 fallback instead of throwing on invalid bytes.
- Skip spine items with no manifest entry instead of throwing an error.
- Scan the zip for a .opf if container.xml is missing.
- Image inlining rewrites the src URL string, not the whole <img> tag (scalpel
  re-serialises tags, so a whole-tag replace silently missed self-closing imgs).

Navigation:
- Hierarchical TOC: ncxToc (EPUB 2), navToc (EPUB 3), guideToc (<guide> fallback). Nested scrapers use atDepth 1 to take only direct children.

UI:
- Streaming: render the opened chapter on demand, cache it, preload neighbours so arrow paging is instant with a placeholder that covers uncached jumps.
- Sidebar shows the TOC tree with CSS styling. Clicks scroll to #fragments.
- wrapHtml wraps each body in a full HTML document for reliable WebKit render.

Test: 'svitak-test <book> <idx>' dumps one rendered chapter. cabal test stanza fixed to list all needed modules/deps.
This commit is contained in:
Marko Andjelic 2026-06-26 03:41:20 +01:00
commit 2011d638b0
Signed by: marko
GPG key ID: 9C5E99C8C682FB59
7 changed files with 738 additions and 427 deletions

View file

@ -1,192 +1,272 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE FlexibleContexts #-}
-- The sole BookInfo instance lives here rather than in Types (which must not
-- depend on the parser); the orphan warning is expected.
{-# OPTIONS_GHC -Wno-orphans #-}
-- | Turns a @.epub@ file into a 'BookStructure' (cheap) plus on-demand
-- 'Chapter' rendering.
--
-- 1. 'openEpub' — unzip, locate the OPF, parse metadata\/manifest\/version
-- 2. 'loadStructure' — resolve the spine (reading order) and the TOC tree
-- 3. 'renderChapter' — read one chapter and inline its images, when visited
module EpubParser
( EpubAction,
EpubEnv (..),
( EpubEnv (..),
openEpub,
getOpfPath,
resolveSpine,
getChapter,
)
where
import Codec.Archive.Zip (Archive, findEntryByPath, fromEntry, toArchive)
import Codec.Archive.Zip (Archive, Entry, filesInArchive, findEntryByPath, fromEntry, toArchive)
import qualified Codec.Epub.Data.Manifest as DMan
import qualified Codec.Epub.Data.Metadata as DMeta
import Codec.Epub.Data.Package (Package (..))
import qualified Codec.Epub.Data.Spine as DSpin
import Codec.Epub.Parse (getManifest, getMetadata, getSpine)
import Codec.Epub.Parse (getManifest, getMetadata, getPackage)
import qualified Codec.Epub.Parse as EParse
import Control.Applicative ((<|>))
import Control.Monad (guard)
import Control.Monad.Except (runExceptT, ExceptT, throwError)
import Control.Monad.Reader (MonadReader (ask), runReaderT)
import Control.Monad.Except (ExceptT, runExceptT)
import Control.Monad.Reader (ask, runReaderT)
import Control.Monad.Trans (lift)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Lazy as B
import Data.List (find, foldl')
import Data.Maybe (fromMaybe, listToMaybe)
import Data.List (find, isSuffixOf)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.FilePath (normalise, takeDirectory, (</>))
import Text.HTML.Scalpel (Scraper, ScraperT, anySelector, attr, chroot, chroots, html, innerHTML, scrapeStringLike, text)
import Types (Chapter (..), EpubAction, EpubEnv (..), InternalPath(..), ChapterIndex(..), BookInfo(..), EpubType(..))
import Navigation (navFinder, lndmkmap, getLandmarks)
import Codec.Epub.Parse (getPackage)
import Codec.Epub.Data.Package (Package(..))
import qualified Codec.Epub.Data.Metadata as DMeta
import System.FilePath (joinPath, splitDirectories, takeDirectory, (</>))
import Text.HTML.Scalpel (Scraper, anySelector, attr, chroots, innerHTML, scrapeStringLike)
import Types
( BookInfo (..),
BookStructure (..),
Chapter (..),
ChapterIndex (..),
ChapterRef (..),
EpubAction,
EpubEnv (..),
InternalPath (..),
TocEntry (..),
)
import Navigation (RawToc (..), guideToc, navDocHref, navToc, ncxToc)
-- | The UI talks to a book only through this interface, so it never has to
-- know it's dealing with an EPUB specifically.
instance BookInfo EpubEnv where
getTitle env = fromMaybe "Unknown Title" $ T.pack . DMeta.titleText <$> listToMaybe (DMeta.metaTitles $ eMetadata env)
bookTitle env =
fromMaybe "Unknown Title" $
T.pack . DMeta.titleText <$> listToMaybe (DMeta.metaTitles (eMetadata env))
getAuthors env = map (T.pack . DMeta.creatorText) $ DMeta.metaCreators $ eMetadata env
bookAuthors env = map (T.pack . DMeta.creatorText) (DMeta.metaCreators (eMetadata env))
getLandmarks = Navigation.getLandmarks
bookFilePath = bookPath
getBookPath = bookPath
loadStructure env = runExceptT (runReaderT buildStructure env)
renderChapter env ref = runExceptT (runReaderT (renderRef ref) env)
getSpine env = runExceptT (runReaderT resolveSpine env)
loadChapter env path idx = runExceptT (runReaderT (getChapter path idx) env)
--------------------------------------------------------------------------------
-- Opening the archive
--------------------------------------------------------------------------------
-- | Read the @.epub@ off disk and gather everything the parser needs. Returns
-- @Left@ with a human-readable reason on any failure.
openEpub :: FilePath -> IO (Either String EpubEnv)
openEpub path = do
rawZip <- B.readFile path
let arch = toArchive rawZip
arch <- toArchive <$> B.readFile path
case getOpfPath arch >>= \opf -> (,) opf <$> findEntryByPath opf arch of
Nothing -> pure (Left "Could not find the OPF package document")
Just (opfPath, entry) -> do
let xml = T.unpack (decodeEntry entry)
meta <- runExceptT (getMetadata xml)
man <- runExceptT (getManifest xml)
ver <- runExceptT (detectVersion xml)
pure $ case (meta, man, ver) of
(Left err, _, _) -> Left ("Metadata parse error: " ++ err)
(_, Left err, _) -> Left ("Manifest parse error: " ++ err)
(_, _, Left err) -> Left ("Version parse error: " ++ err)
(Right m, Right mn, Right v) ->
Right
EpubEnv
{ archive = arch,
opfXml = xml,
baseDir = takeDirectory opfPath,
bookPath = path,
eMetadata = m,
eManifest = mn,
eVersion = T.pack v
}
case getOpfPath arch of
Nothing -> pure $ Left "Could not find OPF path"
Just opfPath ->
case findEntryByPath opfPath arch of
Nothing -> pure $ Left "OPF not in archive"
Just entry -> do
let xml = T.unpack $ TE.decodeUtf8 $ B.toStrict $ fromEntry entry
metaResult <- runExceptT $ getMetadata xml
manResult <- runExceptT $ getManifest xml
verResult <- runExceptT $ detectVersion xml
case (metaResult, manResult, verResult) of
(Left err, _, _) -> pure $ Left $ "Metadata parse error: " ++ err
(_, Left err, _) -> pure $ Left $ "Manifest parse error: " ++ err
(_, _, Left err) -> pure $ Left $ "Version parse error: " ++ err
(Right meta, Right man, Right v) ->
pure $
Right $
EpubEnv
{ archive = arch,
opfXml = xml,
baseDir = takeDirectory opfPath,
bookPath = path,
eMetadata = meta,
eManifest = man,
eVersion = T.pack v
}
(Left err, _, _) -> pure $ Left $ "Metadata error: " ++ err
(_, Left err, _) -> pure $ Left $ "Manifest error: " ++ err
(_, _, Left err) -> pure $ Left $ "Version error: " ++ err
getOpfPath :: Archive -> Maybe String
getOpfPath arch =
maybe Nothing (\e -> getRootPath (TE.decodeUtf8 . B.toStrict $ fromEntry e)) (findEntryByPath "META-INF/container.xml" arch)
getRootPath :: T.Text -> Maybe String
getRootPath rawhtml =
scrapeStringLike rawhtml $ T.unpack <$> attr "full-path" "rootfile"
resolveSpine :: EpubAction [(InternalPath, T.Text)]
resolveSpine = do
env <- ask
let xmlStr = opfXml env
let (DMan.Manifest items) = eManifest env
spineResult <- lift $ Codec.Epub.Parse.getSpine xmlStr
case spineResult of
DSpin.Spine maybeTocId refs -> do
let lookupHref ident = DMan.mfiHref <$> find (\mi -> DMan.mfiId mi == ident) items
let mNavPath = scrapeStringLike xmlStr navFinder
tocMap <- case mNavPath of
Just navPath -> extractTocFromEntry (baseDir env </> navPath)
Nothing -> case lookupHref maybeTocId of
Just ncxPath -> extractTocFromEntry (baseDir env </> ncxPath)
Nothing -> throwError "Could not locate a Table of Contents."
let buildChapter (ref, i) = do
let ident = DSpin.siIdRef ref
case lookupHref ident of
Nothing -> throwError $ "Manifest missing entry for spine item: " ++ ident
Just p ->
let title = fromMaybe ("Chapter " <> T.pack (show i)) (lookup p tocMap)
in pure (InternalPath p, title)
mapM buildChapter (zip refs [1..])
extractTocFromEntry :: FilePath -> EpubAction [(String, T.Text)]
extractTocFromEntry fullPath = do
env <- ask
case findEntryByPath (normalise fullPath) (archive env) of
Nothing -> pure []
Just e -> do
let raw = TE.decodeUtf8 . B.toStrict $ fromEntry e
let types = [EpubType "toc", EpubType "bodymatter"]
pure $ fromMaybe [] (scrapeStringLike raw (lndmkmap types) <|> scrapeStringLike raw ncxScraper)
getChapter :: (InternalPath, T.Text) -> ChapterIndex -> EpubAction Chapter
getChapter (InternalPath filename, tocTitle) idx = do
env <- ask
let bare = takeWhile (/= '#') filename
let full = normalise (baseDir env </> bare)
case findEntryByPath full (archive env) of
Nothing -> pure $ Chapter tocTitle "" idx
Just e -> do
let rawText = TE.decodeUtf8 . B.toStrict $ fromEntry e
let withImages = embedImages env rawText
let (_, content) = fromMaybe (tocTitle, "") $ scrapeStringLike withImages chapterScraper
pure $ Chapter tocTitle content idx
getMimeFromManifest :: DMan.Manifest -> FilePath -> T.Text
getMimeFromManifest (DMan.Manifest items) relPath =
case find (\item -> DMan.mfiHref item == relPath) items of
Just item -> T.pack $ DMan.mfiMediaType item
Nothing -> "image/jpeg"
-- translate a file path into base64 then formats it like "data:image/jpeg;<base64 string>"
findImage :: EpubEnv -> T.Text -> T.Text
findImage env path =
let fullPath = normalise (baseDir env </> T.unpack path)
in case findEntryByPath fullPath (archive env) of
Nothing -> path
Just entry -> "data:" <> mime <> ";base64," <> b64
where
rawData = B.toStrict $ fromEntry entry
b64 = TE.decodeUtf8 $ B64.encode rawData
mime = getMimeFromManifest (eManifest env) (T.unpack path)
imgScraper :: (Monad m) => EpubEnv -> ScraperT T.Text m [(T.Text, T.Text)]
imgScraper env = chroots "img" $ (\s h -> (h, T.replace s (findImage env s) h)) <$> attr "src" anySelector <*> html anySelector
applyReplacements :: T.Text -> [(T.Text, T.Text)] -> T.Text
applyReplacements = foldl' (\acc (old, new) -> T.replace old new acc)
embedImages :: EpubEnv -> T.Text -> T.Text
embedImages env htmlContent =
maybe htmlContent (applyReplacements htmlContent) (scrapeStringLike htmlContent (imgScraper env))
chTitleScraper :: Scraper T.Text T.Text
chTitleScraper = do
t <- text "h1" <|> text "h2" <|> text "h3" <|> text "title"
let cleanT = T.strip t
guard $ not $ T.null cleanT
pure cleanT
chapterScraper :: Scraper T.Text (T.Text, T.Text)
chapterScraper = do
title <- chTitleScraper <|> pure "Untitled Chapter"
content <- chroot "body" (innerHTML anySelector) <|> innerHTML anySelector
pure (title, content)
ncxScraper :: Scraper T.Text [(String, T.Text)]
ncxScraper = chroots "navPoint" $
(,) <$> (T.unpack <$> attr "src" "content") <*> (T.strip <$> text "navLabel")
-- | Locate the OPF. Normally it's announced in @META-INF/container.xml@; if
-- that's missing or malformed, fall back to scanning the archive for a @.opf@.
getOpfPath :: Archive -> Maybe FilePath
getOpfPath arch = fromContainer <|> scanArchive
where
fromContainer = do
entry <- findEntryByPath "META-INF/container.xml" arch
scrapeStringLike (decodeEntry entry) (T.unpack <$> attr "full-path" "rootfile")
scanArchive = find (".opf" `isSuffixOf`) (filesInArchive arch)
detectVersion :: String -> ExceptT String IO String
detectVersion = fmap pkgVersion . getPackage
--------------------------------------------------------------------------------
-- Structure (spine + table of contents)
--------------------------------------------------------------------------------
-- | Resolve the reading order and the TOC tree without rendering any bodies.
-- Spine items whose manifest entry is missing are skipped rather than aborting
-- the whole load.
buildStructure :: EpubAction BookStructure
buildStructure = do
env <- ask
let DMan.Manifest items = eManifest env
hrefOf ident = DMan.mfiHref <$> find ((== ident) . DMan.mfiId) items
DSpin.Spine tocId refs <- lift (EParse.getSpine (opfXml env))
toc <- buildToc hrefOf tocId
let titles = M.fromListWith (\_new old -> old) [(tocTarget t, tocLabel t) | t <- flattenToc toc]
paths = mapMaybe (fmap (InternalPath . toFullPath env) . hrefOf . DSpin.siIdRef) refs
titleFor path n = M.findWithDefault ("Chapter " <> T.pack (show (n + 1))) path titles
chapterRefs =
[ ChapterRef path (titleFor path n) (ChapterIndex n)
| (path, n) <- zip paths [0 ..]
]
pure (BookStructure chapterRefs toc)
-- | Build the TOC tree, trying each source in order of richness:
-- EPUB 3 nav document, then EPUB 2 NCX, then the EPUB 2 @\<guide\>@.
buildToc :: (String -> Maybe FilePath) -> String -> EpubAction [TocEntry]
buildToc hrefOf tocId = do
env <- ask
let fromDoc mhref scraper = case mhref of
Nothing -> pure []
Just href -> readTocDoc (toFullPath env href) scraper
nav <- fromDoc (navDocHref (opfXml env)) navToc
ncx <- fromDoc (hrefOf tocId) ncxToc
let guide = resolveRaws (baseDir env) (fromMaybe [] (scrapeStringLike (T.pack (opfXml env)) guideToc))
pure (firstNonEmpty [nav, ncx, guide])
-- | Read a TOC document and resolve its entries' hrefs against its own
-- location.
readTocDoc :: FilePath -> Scraper T.Text [RawToc] -> EpubAction [TocEntry]
readTocDoc docPath scraper = do
env <- ask
pure $ case findEntry env docPath of
Nothing -> []
Just entry -> resolveRaws (takeDirectory docPath) (fromMaybe [] (scrapeStringLike (decodeEntry entry) scraper))
-- | Resolve raw (document-relative) hrefs into full archive paths, splitting
-- off any @#fragment@.
resolveRaws :: FilePath -> [RawToc] -> [TocEntry]
resolveRaws dir = map go
where
go (RawToc label href kids) =
let (path, frag) = breakFragment href
in TocEntry label (InternalPath (resolveZipPath (dir </> path))) frag (resolveRaws dir kids)
flattenToc :: [TocEntry] -> [TocEntry]
flattenToc = concatMap (\t -> t : flattenToc (tocChildren t))
--------------------------------------------------------------------------------
-- Rendering a single chapter
--------------------------------------------------------------------------------
-- | Read a chapter file and render its @\<body\>@ to self-contained HTML.
renderRef :: ChapterRef -> EpubAction Chapter
renderRef ref = do
env <- ask
let InternalPath p = refPath ref
pure $ case findEntry env p of
Nothing -> Chapter (refPath ref) (refTitle ref) "" (refIndex ref)
Just entry ->
let withImages = embedImages env (takeDirectory p) (decodeEntry entry)
body = fromMaybe "" (scrapeStringLike withImages bodyScraper)
in Chapter (refPath ref) (refTitle ref) body (refIndex ref)
-- | Grab the contents of @\<body\>@, falling back to the whole document.
bodyScraper :: Scraper T.Text T.Text
bodyScraper = innerHTML "body" <|> innerHTML anySelector
--------------------------------------------------------------------------------
-- Inlining images
--------------------------------------------------------------------------------
-- | Inline images by rewriting each distinct @<img>@ @src@ URL to a base64
-- @data:@ URI, so the rendered HTML is self-contained. We replace the URL
-- string itself (not the whole tag) because scalpel re-serialises tags — e.g.
-- @<img .../>@ becomes @<img ...></img>@ — which would never match the source
-- text. Image @src@s are relative to the chapter file, hence @chapterDir@.
embedImages :: EpubEnv -> FilePath -> T.Text -> T.Text
embedImages env chapterDir content =
foldr embed content (distinct (fromMaybe [] (scrapeStringLike content imgSrcs)))
where
imgSrcs = chroots "img" (attr "src" anySelector) :: Scraper T.Text [T.Text]
embed src acc = T.replace src (dataUri env chapterDir src) acc
distinct = M.keys . M.fromList . map (\s -> (s, ()))
-- | Turn an image href into a @data:<mime>;base64,<...>@ URI, or leave it
-- untouched if the file isn't in the archive.
dataUri :: EpubEnv -> FilePath -> T.Text -> T.Text
dataUri env chapterDir src =
case findEntryByPath path (archive env) of
Nothing -> src
Just entry ->
let b64 = TE.decodeUtf8 (B64.encode (B.toStrict (fromEntry entry)))
in "data:" <> mimeFor env path <> ";base64," <> b64
where
path = resolveZipPath (chapterDir </> T.unpack src)
-- | An entry's declared media type, looked up in the manifest by full path.
mimeFor :: EpubEnv -> FilePath -> T.Text
mimeFor env fullPath =
case find ((== fullPath) . toFullPath env . DMan.mfiHref) items of
Just item -> T.pack (DMan.mfiMediaType item)
Nothing -> "image/jpeg"
where
DMan.Manifest items = eManifest env
--------------------------------------------------------------------------------
-- Paths, decoding, small helpers
--------------------------------------------------------------------------------
findEntry :: EpubEnv -> FilePath -> Maybe Entry
findEntry env path = findEntryByPath path (archive env)
-- | Resolve a manifest/TOC href (relative to the OPF) to a full archive path.
toFullPath :: EpubEnv -> FilePath -> FilePath
toFullPath env href = resolveZipPath (baseDir env </> href)
-- | Normalise an already-joined path: drop any @#fragment@ and collapse @.@
-- and @..@ segments. 'System.FilePath.normalise' does /not/ collapse @..@,
-- which is why relative hrefs like @../Images/p1.jpg@ would otherwise miss.
resolveZipPath :: FilePath -> FilePath
resolveZipPath raw =
joinPath (reverse (foldl step [] (splitDirectories (fst (breakFragment raw)))))
where
step acc "." = acc
step (_ : rest) ".." = rest
step acc ".." = acc
step acc seg = seg : acc
-- | Split an href into its path and (un-@#@-prefixed) fragment.
breakFragment :: FilePath -> (FilePath, T.Text)
breakFragment href = (path, T.pack (drop 1 frag))
where
(path, frag) = break (== '#') href
firstNonEmpty :: [[a]] -> [a]
firstNonEmpty = fromMaybe [] . find (not . null)
-- | Decode a zip entry to text, honouring a BOM and tolerating non-UTF-8 bytes
-- (falling back to Latin-1) rather than throwing.
decodeEntry :: Entry -> T.Text
decodeEntry = decodeBytes . B.toStrict . fromEntry
decodeBytes :: BS.ByteString -> T.Text
decodeBytes bs
| "\xEF\xBB\xBF" `BS.isPrefixOf` bs = decodeBytes (BS.drop 3 bs)
| "\xFF\xFE" `BS.isPrefixOf` bs = TE.decodeUtf16LE (BS.drop 2 bs)
| "\xFE\xFF" `BS.isPrefixOf` bs = TE.decodeUtf16BE (BS.drop 2 bs)
| otherwise = either (const (TE.decodeLatin1 bs)) id (TE.decodeUtf8' bs)

View file

@ -1,74 +1,71 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Scrapers that turn a table-of-contents document into a /tree/ of raw
-- entries. Two formats are supported:
--
-- * EPUB 2 — an NCX file with nested @\<navPoint\>@s.
-- * EPUB 3 — a nav document with nested @\<ol\>@\/@\<li\>@ lists.
--
-- Hrefs are left relative here; 'EpubParser' resolves them against the TOC
-- document's location. We use 'atDepth' so each recursion level only picks up
-- its /direct/ children (otherwise scalpel's descendant matching would flatten
-- the hierarchy and duplicate nested nodes).
module Navigation
( navFinder,
lndmkScraper,
lndmkmap,
getLandmarks
( RawToc (..),
navDocHref,
ncxToc,
navToc,
guideToc,
)
where
import Text.HTML.Scalpel (Scraper, attr, (@:), (@=), chroot, chroots, text, match, scrapeStringLike)
import Types (NavItem(..), EpubType (..), InternalPath(..), EpubEnv(..))
import Data.Maybe (fromMaybe)
import qualified Data.ByteString.Lazy as B
import Control.Applicative (optional)
import Control.Monad (guard)
import Codec.Archive.Zip
import Control.Applicative ((<|>))
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.FilePath (normalise, (</>))
import Text.HTML.Scalpel (Scraper, atDepth, attr, chroot, chroots, scrapeStringLike, text, (@:), (@=))
-- ------------------------------------------------------------
-- EPub 3 navigation
-- ------------------------------------------------------------
-- The epub-metadata library is good for epub 2.x. As there could be a seperate nav.xhtml file in the epub 3.x spec, we need these functions to support it.
-- | A TOC node with an as-yet-unresolved (document-relative) href.
data RawToc = RawToc
{ rawLabel :: T.Text,
rawHref :: FilePath,
rawChildren :: [RawToc]
}
deriving (Show)
navFinder :: Scraper String FilePath
navFinder = attr "href" ("item" @: ["properties" @= "nav"])
itemScraper :: [EpubType] -> Scraper T.Text NavItem
itemScraper allowedTypes = do
label <- text "a"
path <- attr "href" "a"
rawTypes <- fromMaybe "" <$> optional (attr "epub:type" "a")
let foundTypes = map EpubType $ T.words rawTypes
guard $ any (`elem` allowedTypes) foundTypes
pure $ NavItem label (InternalPath $ T.unpack path) foundTypes
lndmkScraper :: [EpubType] -> Scraper T.Text [NavItem]
lndmkScraper labels =
chroot ("nav" @: [match navPredicate]) $ chroots "li" (itemScraper labels)
-- | The EPUB 3 navigation document's href, declared in the OPF manifest as the
-- item carrying @properties="nav"@.
navDocHref :: String -> Maybe FilePath
navDocHref opf =
scrapeStringLike opf (attr "href" ("item" @: ["properties" @= "nav"]))
-- | EPUB 2: walk the @\<navMap\>@ of nested @\<navPoint\>@s.
ncxToc :: Scraper T.Text [RawToc]
ncxToc = chroot "navMap" points
where
navPredicate "epub:type" xs = EpubType (T.pack xs) `elem` labels
navPredicate _ _ = False
points = chroots ("navPoint" `atDepth` 1) node
node =
RawToc
<$> (T.strip <$> text ("navLabel" `atDepth` 1))
<*> (T.unpack <$> attr "src" ("content" `atDepth` 1))
<*> points
lndmkmap :: [EpubType] -> Scraper T.Text [(String, T.Text)]
lndmkmap types =
map (\item -> (unpackPath (navPath item), navLabel item)) <$> lndmkScraper types
-- | EPUB 3: walk @\<nav epub:type="toc"\>@'s nested @\<ol\>@\/@\<li\>@ lists.
navToc :: Scraper T.Text [RawToc]
navToc = chroot ("nav" @: ["epub:type" @= "toc"]) (chroot "ol" items)
where
unpackPath (InternalPath p) = p
items = chroots ("li" `atDepth` 1) node
node =
RawToc
<$> (T.strip <$> text ("a" `atDepth` 1))
<*> (T.unpack <$> attr "href" ("a" `atDepth` 1))
<*> (chroot ("ol" `atDepth` 1) items <|> pure [])
--------------------------------------------------------
-- dispatching --
--------------------------------------------------------
getLandmarks :: EpubEnv -> [NavItem]
getLandmarks env =
case eVersion env of
"2.0" -> []
"3.0" ->
case scrapeStringLike (opfXml env) navFinder of
Nothing -> []
Just relPath ->
let fullPath = normalise $ (baseDir env) </> relPath
in case findEntryByPath fullPath (archive env) of
Nothing -> []
Just entry ->
let content = TE.decodeUtf8 . B.toStrict $ fromEntry entry
types = map EpubType ["toc", "landmarks", "bodymatter"]
scrp = lndmkScraper types
in fromMaybe [] $ scrapeStringLike content scrp
-- | EPUB 2 @\<guide\>@: a flat list of @\<reference\>@s (cover, toc, start, …).
-- Used as a last-resort TOC when there's no NCX or nav document.
guideToc :: Scraper T.Text [RawToc]
guideToc = chroot "guide" $ chroots "reference" node
where
node =
RawToc
<$> (T.strip <$> attr "title" "reference")
<*> (T.unpack <$> attr "href" "reference")
<*> pure []

View file

@ -1,41 +1,22 @@
{-# LANGUAGE OverloadedStrings #-}
module State
( initialState,
prepareView
)
where
-- | The mutable application state's initial value. Navigation logic lives in
-- the UI, which renders chapters lazily into 'stCache'.
module State (initialState) where
import Types (AppState(..), Chapter(chapterTags, chapterTitle), ChapterView(..), EpubEnv, ChapterIndex(..))
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import Types (AppState (..), ChapterIndex (..))
-- Get an element from a list without crashing
safeGet :: ChapterIndex -> [a] -> Maybe a
safeGet (ChapterIndex n) xs
| n < 0 = Nothing
| otherwise = case drop n xs of
(x : _) -> Just x
[] -> Nothing
-- loading template
initialState :: EpubEnv -> AppState
initialState env =
-- | Empty state shown while the book's structure loads in the background.
initialState :: T.Text -> FilePath -> AppState
initialState title path =
AppState
{ cEnv = env,
cIdx = ChapterIndex 0,
cSpine = [],
bTitle = "Svitak - Loading...",
zoomlvl = 1.0,
activeTask = Nothing,
cAllChapters = [],
taskVersion = 0
}
-- prepare the data for the UI
prepareView :: AppState -> ChapterIndex -> Maybe ChapterView
prepareView st index = do
chapter <- safeGet index (cAllChapters st)
pure $ ChapterView
{ viewTitle = bTitle st <> " - " <> chapterTitle chapter
, viewHtml = chapterTags chapter
, viewIdx = index
{ stRefs = [],
stToc = [],
stCache = M.empty,
stIndex = ChapterIndex 0,
stZoom = 1.0,
stTitle = title,
stBookPath = path
}

View file

@ -1,47 +1,117 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-- | Shared types for the whole reader. The parser ('EpubParser') produces a
-- 'BookStructure' cheaply and renders each 'Chapter' on demand; the UI
-- consumes both through the 'BookInfo' interface.
module Types
( EpubEnv(..),
ChapterView(..),
Chapter(..),
AppState(..),
UserAction(..),
( -- * Identifiers
ChapterIndex (..),
InternalPath (..),
-- * Book content
Chapter (..),
ChapterRef (..),
TocEntry (..),
BookStructure (..),
-- * The book interface the UI talks to
BookInfo (..),
-- * UI state
AppState (..),
UserAction (..),
-- * EPUB environment
EpubEnv (..),
EpubAction,
NavItem(..),
ChapterIndex(..),
InternalPath(..),
EpubType(..),
BookInfo(..)
)
where
import qualified Data.Text as T
import Codec.Archive.Zip (Archive)
import qualified Codec.Epub.Data.Metadata as DMeta
import qualified Codec.Epub.Data.Manifest as DMan
import Control.Concurrent.Async (Async)
import Control.Monad.Reader (ReaderT)
import Data.String (IsString)
import qualified Codec.Epub.Data.Metadata as DMeta
import Control.Monad.Except (ExceptT)
import Control.Monad.Reader (ReaderT)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
-- Unified interface for EPub 2 and EPub 3 formats, allows generic programming of both without having to repeat boilerplate
class BookInfo a where
getTitle :: a -> T.Text
getAuthors :: a -> [T.Text]
getLandmarks :: a -> [NavItem]
getBookPath :: a -> FilePath
getSpine :: a -> IO (Either String [(InternalPath, T.Text)])
loadChapter :: a -> (InternalPath, T.Text) -> ChapterIndex -> IO (Either String Chapter)
-- | Position of a chapter within the linear reading order (the spine).
newtype ChapterIndex = ChapterIndex Int
deriving (Show, Eq, Ord, Num)
-- | A fully-resolved path to a file inside the EPUB zip.
newtype InternalPath = InternalPath FilePath
deriving (Show, Eq)
deriving (Show, Eq, Ord)
newtype EpubType = EpubType T.Text
deriving (Show,Eq, IsString)
-- | A lightweight pointer to a chapter: enough to navigate and label it in the
-- sidebar without having rendered its (potentially large) content yet.
data ChapterRef = ChapterRef
{ refPath :: InternalPath,
refTitle :: T.Text,
refIndex :: ChapterIndex
}
deriving (Show)
-- | A chapter rendered to self-contained HTML, ready for WebKit.
data Chapter = Chapter
{ chapterPath :: InternalPath,
chapterTitle :: T.Text,
chapterHtml :: T.Text,
chapterIndex :: ChapterIndex
}
deriving (Show)
-- | A node in the (possibly nested) table of contents. @tocTarget@ is the
-- resolved chapter path; @tocFragment@ is the optional in-page anchor.
data TocEntry = TocEntry
{ tocLabel :: T.Text,
tocTarget :: InternalPath,
tocFragment :: T.Text,
tocChildren :: [TocEntry]
}
deriving (Show)
-- | The cheap-to-compute skeleton of a book: reading order + navigation tree.
data BookStructure = BookStructure
{ structRefs :: [ChapterRef],
structToc :: [TocEntry]
}
-- | The UI only ever sees a book through this interface, so it stays
-- format-agnostic. The single instance ('EpubEnv') lives in 'EpubParser'.
class BookInfo a where
bookTitle :: a -> T.Text
bookAuthors :: a -> [T.Text]
bookFilePath :: a -> FilePath
-- | Resolve the spine and TOC. Fast: does not render chapter bodies.
loadStructure :: a -> IO (Either String BookStructure)
-- | Render a single chapter's body to self-contained HTML, on demand.
renderChapter :: a -> ChapterRef -> IO (Either String Chapter)
-- | Mutable application state, held in a 'Control.Concurrent.STM.TVar'.
-- Rendered chapters accumulate in 'stCache' as the reader visits them.
data AppState = AppState
{ stRefs :: [ChapterRef],
stToc :: [TocEntry],
stCache :: M.Map ChapterIndex Chapter,
stIndex :: ChapterIndex,
stZoom :: Double,
stTitle :: T.Text,
stBookPath :: FilePath
}
-- | A user request, produced by the keyboard / sidebar and handled centrally.
-- 'GoToChapter' carries an optional anchor to scroll to within the chapter.
data UserAction
= NextChapter
| PrevChapter
| ZoomIn
| ZoomOut
| GoToChapter ChapterIndex T.Text
-- | Parsed EPUB, carried through the parser as a reader environment.
data EpubEnv = EpubEnv
{ archive :: Archive,
opfXml :: String,
@ -52,40 +122,5 @@ data EpubEnv = EpubEnv
eVersion :: T.Text
}
data ChapterView = ChapterView
{ viewTitle :: T.Text,
viewHtml :: T.Text,
viewIdx :: ChapterIndex
}
data Chapter = Chapter
{ chapterTitle :: T.Text,
chapterTags :: T.Text,
chapterIdx :: ChapterIndex
}
data AppState = AppState
{ cEnv :: EpubEnv,
cIdx :: ChapterIndex,
zoomlvl :: Double,
cSpine :: [(InternalPath, T.Text)],
bTitle :: T.Text,
cAllChapters :: [Chapter],
activeTask :: Maybe (Async ()),
taskVersion :: Integer
}
-- | The parser monad: read-only access to the 'EpubEnv', with string errors.
type EpubAction a = ReaderT EpubEnv (ExceptT String IO) a
data UserAction
= NextChapter
| PrevChapter
| ZoomIn
| ZoomOut
| LoadSpecific ChapterIndex
data NavItem = NavItem
{ navLabel :: T.Text,
navPath :: InternalPath,
navTypes :: [EpubType]
} deriving (Show)

338
app/UI.hs
View file

@ -1,13 +1,19 @@
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
-- | The GTK4 / WebKit front end. The book's structure loads in the background;
-- chapters are rendered lazily (and cached) the first time they're visited,
-- with neighbours preloaded so arrow-key paging feels instant.
module UI (runApp) where
import Control.Concurrent.Async (async)
import Control.Concurrent.STM (TVar, atomically, modifyTVar, newTVarIO, readTVar)
import Control.Monad (zipWithM)
import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO)
import Control.Monad (unless, void, when)
import Data.GI.Base (AttrOp (..), on)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import EpubParser (EpubEnv (..))
import Data.Word (Word32)
import EpubParser (EpubEnv) -- brings the `BookInfo EpubEnv` instance into scope
import qualified GI.GLib as GLib
import qualified GI.GLib.Constants as GLibConst
import qualified GI.Gdk as Gdk
@ -16,87 +22,117 @@ import qualified GI.Gtk as Gtk
import qualified GI.Gtk.Enums as GtkEnums
import qualified GI.WebKit as WebKit
import Persistence (loadLastRead, saveLastRead)
import State (initialState, prepareView)
import Types (AppState (..), ChapterView (..), UserAction (..), ChapterIndex(..), BookInfo (..))
import State (initialState)
import Types
( AppState (..),
BookInfo (..),
BookStructure (..),
Chapter (..),
ChapterIndex (..),
ChapterRef (..),
TocEntry (..),
UserAction (..),
)
updateUI :: Gtk.ApplicationWindow -> WebKit.WebView -> ChapterView -> ChapterIndex -> Int -> IO ()
updateUI window webView view (ChapterIndex currentIdx) totalCount = do
let ttext = T.pack (show (currentIdx + 1)) <> "/ " <> T.pack (show totalCount) <> ": " <> viewTitle view
Gtk.set window [#title := ttext]
WebKit.webViewLoadHtml webView (viewHtml view) Nothing
-- | Widget handles the rest of the module needs.
data AppWidgets = AppWidgets
{ appWindow :: Gtk.ApplicationWindow,
appWebView :: WebKit.WebView,
appSidebar :: Gtk.ListBox
}
handleAction :: TVar AppState -> Gtk.ApplicationWindow -> WebKit.WebView -> UserAction -> IO ()
handleAction stateTVar window webView action = do
st <- atomically $ readTVar stateTVar
let totalChapters = length (cAllChapters st)
-- | Everything an action handler needs: shared state, widgets, and a way to
-- render a chapter on demand (closes over the book).
data Ctx = Ctx
{ ctxState :: TVar AppState,
ctxWidgets :: AppWidgets,
ctxRender :: ChapterRef -> IO (Either String Chapter)
}
case action of
ZoomIn -> updateZoom stateTVar webView (zoomlvl st + 0.1)
ZoomOut -> updateZoom stateTVar webView (max 0.1 (zoomlvl st - 0.1))
_ -> do
if null (cAllChapters st)
then pure ()
else do
let nextIdx = case action of
NextChapter -> cIdx st + 1
PrevChapter -> cIdx st - 1
LoadSpecific i -> i
--------------------------------------------------------------------------------
-- Widget tree
--------------------------------------------------------------------------------
let safeIdx = max 0 (min (ChapterIndex (totalChapters - 1)) nextIdx)
case prepareView st safeIdx of
Nothing -> pure ()
Just view -> do
updateUI window webView view safeIdx totalChapters
atomically $ modifyTVar stateTVar $ \s -> s {cIdx = viewIdx view}
_ <- async $ saveLastRead (bookPath (cEnv st)) (viewIdx view)
pure ()
updateZoom :: TVar AppState -> WebKit.WebView -> Double -> IO ()
updateZoom stateTVar webView level = do
atomically $ modifyTVar stateTVar $ \s -> s {zoomlvl = level}
WebKit.webViewSetZoomLevel webView level
initAppServices :: (BookInfo a) => a -> TVar AppState -> (UserAction -> IO ()) -> IO ()
initAppServices env stateTVar actionHandler = do
_ <- async $ do
spineResult <- getSpine env
case spineResult of
Left err -> putStrLn $ "Failed to load spine: " ++ err
Right ispine -> do
chapterResults <- zipWithM (loadChapter env) ispine (map ChapterIndex [0 ..])
case sequence chapterResults of
Left err -> putStrLn $ "Failed to load chapters: " ++ err
Right allChapters -> do
let bookTitle = getTitle env
atomically $ modifyTVar stateTVar $ \s ->
s { cSpine = ispine, cAllChapters = allChapters, bTitle = bookTitle }
_ <- GLib.idleAdd GLibConst.PRIORITY_DEFAULT $ do
maybeSaved <- loadLastRead
case maybeSaved of
Just (path, idx) | path == getBookPath env -> actionHandler (LoadSpecific idx)
_ -> actionHandler (LoadSpecific 0)
pure False
pure ()
pure ()
activate :: EpubEnv -> Gtk.Application -> IO ()
activate env app = do
stateTVar <- newTVarIO (initialState env)
buildLayout :: Gtk.Application -> IO AppWidgets
buildLayout app = do
window <- Gtk.applicationWindowNew app
webView <- WebKit.webViewNew
sidebar <- Gtk.listBoxNew
Gtk.listBoxSetSelectionMode sidebar GtkEnums.SelectionModeSingle
Gtk.widgetAddCssClass sidebar "toc"
Gtk.set window [#defaultWidth := 800, #defaultHeight := 600, #child := webView]
scrolled <- Gtk.scrolledWindowNew
Gtk.scrolledWindowSetChild scrolled (Just sidebar)
Gtk.widgetSetSizeRequest scrolled 260 (-1)
let dispatch = handleAction stateTVar window webView
paned <- Gtk.panedNew GtkEnums.OrientationHorizontal
Gtk.panedSetStartChild paned (Just scrolled)
Gtk.panedSetEndChild paned (Just webView)
Gtk.panedSetResizeStartChild paned False
initAppServices env stateTVar dispatch
Gtk.set window [#defaultWidth := 1000, #defaultHeight := 700, #child := paned]
applyStyles window
pure (AppWidgets window webView sidebar)
-- | Install the app-wide stylesheet (sidebar rows, selection, headings).
applyStyles :: Gtk.ApplicationWindow -> IO ()
applyStyles window = do
provider <- Gtk.cssProviderNew
Gtk.cssProviderLoadFromString provider css
disp <- Gtk.widgetGetDisplay window
Gtk.styleContextAddProviderForDisplay disp provider appPriority
where
appPriority = 600 :: Word32
css =
T.concat
[ ".toc { background: transparent; padding: 4px; }",
".toc > row { padding: 6px 10px; border-radius: 6px; margin: 1px 4px; }",
".toc > row:selected { background-color: rgba(90,130,220,0.35); }",
".toc > row:hover { background-color: rgba(128,128,128,0.14); }",
"label.toc-top { font-weight: bold; }"
]
--------------------------------------------------------------------------------
-- Sidebar (table of contents)
--------------------------------------------------------------------------------
-- | A displayable sidebar row: nesting depth, label, the chapter it jumps to,
-- and an optional in-page anchor.
data Row = Row Int T.Text ChapterIndex T.Text
-- | Flatten the TOC tree into rows, remembering depth (for indentation) and
-- the chapter each entry targets. With no TOC, list chapters flatly.
displayRows :: [ChapterRef] -> [TocEntry] -> [Row]
displayRows refs toc
| null toc = [Row 0 (refTitle r) (refIndex r) "" | r <- refs]
| otherwise = go 0 toc
where
pathToIndex = M.fromList [(refPath r, refIndex r) | r <- refs]
indexOf target = M.findWithDefault (ChapterIndex 0) target pathToIndex
go depth =
concatMap
(\t -> Row depth (tocLabel t) (indexOf (tocTarget t)) (tocFragment t) : go (depth + 1) (tocChildren t))
populateSidebar :: Gtk.ListBox -> [Row] -> IO ()
populateSidebar sidebar =
mapM_ $ \(Row depth label _ _) -> do
lbl <- Gtk.labelNew (Just label)
Gtk.widgetSetHalign lbl GtkEnums.AlignStart
Gtk.labelSetXalign lbl 0
Gtk.labelSetWrap lbl True
Gtk.widgetSetMarginStart lbl (fromIntegral (depth * 14))
when (depth == 0) (Gtk.widgetAddCssClass lbl "toc-top")
Gtk.listBoxInsert sidebar lbl (-1)
--------------------------------------------------------------------------------
-- Events
--------------------------------------------------------------------------------
wireEvents :: Ctx -> IO ()
wireEvents ctx = do
let dispatch = handleAction ctx
keyCtrl <- Gtk.eventControllerKeyNew
Gtk.eventControllerSetPropagationPhase keyCtrl GtkEnums.PropagationPhaseCapture
_ <- on keyCtrl #keyPressed $ \keyval _ _ ->
case keyval of
Gdk.KEY_Right -> dispatch NextChapter >> pure True
@ -104,14 +140,166 @@ activate env app = do
Gdk.KEY_equal -> dispatch ZoomIn >> pure True
Gdk.KEY_minus -> dispatch ZoomOut >> pure True
_ -> pure False
Gtk.widgetAddController (appWindow (ctxWidgets ctx)) keyCtrl
Gtk.widgetAddController window keyCtrl
#present window
_ <- on (appSidebar (ctxWidgets ctx)) #rowActivated $ \row -> do
i <- Gtk.listBoxRowGetIndex row
st <- readTVarIO (ctxState ctx)
case drop (fromIntegral i) (displayRows (stRefs st) (stToc st)) of
(Row _ _ idx frag : _) -> dispatch (GoToChapter idx frag)
[] -> pure ()
pure ()
--------------------------------------------------------------------------------
-- Loading
--------------------------------------------------------------------------------
-- | Load the book's structure off the GTK thread, then populate the sidebar
-- and open the first (or last-read) chapter back on the main loop.
loadBook :: (BookInfo a) => a -> Ctx -> IO ()
loadBook env ctx = void $
async $ do
result <- loadStructure env
case result of
Left err -> putStrLn ("Failed to load book: " ++ err)
Right (BookStructure refs toc) -> do
atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stRefs = refs, stToc = toc}
postGtk $ do
populateSidebar (appSidebar (ctxWidgets ctx)) (displayRows refs toc)
saved <- loadLastRead
let start = case saved of
Just (path, idx) | path == bookFilePath env -> idx
_ -> ChapterIndex 0
handleAction ctx (GoToChapter start "")
--------------------------------------------------------------------------------
-- Navigation + rendering
--------------------------------------------------------------------------------
handleAction :: Ctx -> UserAction -> IO ()
handleAction ctx action = do
st <- readTVarIO (ctxState ctx)
case action of
ZoomIn -> setZoom ctx (stZoom st + 0.1)
ZoomOut -> setZoom ctx (max 0.1 (stZoom st - 0.1))
NextChapter -> goto ctx (stIndex st + 1) ""
PrevChapter -> goto ctx (stIndex st - 1) ""
GoToChapter i frag -> goto ctx i frag
-- | Move to a chapter (clamped), render it if needed, scroll to @frag@, and
-- remember the position. Neighbours are preloaded afterwards.
goto :: Ctx -> ChapterIndex -> T.Text -> IO ()
goto ctx target frag = do
st <- readTVarIO (ctxState ctx)
let refs = stRefs st
total = length refs
when (total > 0) $ do
let ChapterIndex t = target
i = max 0 (min (total - 1) t)
idx = ChapterIndex i
ref = refs !! i
atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stIndex = idx}
void $ async (saveLastRead (stBookPath st) idx)
case M.lookup idx (stCache st) of
Just chapter -> display ctx chapter frag
Nothing -> do
showLoading ctx ref
void $ async $ do
rendered <- ctxRender ctx ref
case rendered of
Left err -> putStrLn ("render failed: " ++ err)
Right chapter -> do
atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stCache = M.insert idx chapter (stCache s)}
postGtk $ do
cur <- stIndex <$> readTVarIO (ctxState ctx)
when (cur == idx) (display ctx chapter frag)
preload ctx
-- | Render the chapters on either side of the current one into the cache.
preload :: Ctx -> IO ()
preload ctx = do
st <- readTVarIO (ctxState ctx)
let refs = stRefs st
total = length refs
ChapterIndex i = stIndex st
mapM_ (cacheRef ctx refs) (filter (\n -> n >= 0 && n < total) [i - 1, i + 1])
cacheRef :: Ctx -> [ChapterRef] -> Int -> IO ()
cacheRef ctx refs n = do
let idx = ChapterIndex n
st <- readTVarIO (ctxState ctx)
unless (M.member idx (stCache st)) $
void $
async $ do
rendered <- ctxRender ctx (refs !! n)
case rendered of
Right chapter -> atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stCache = M.insert idx chapter (stCache s)}
Left _ -> pure ()
display :: Ctx -> Chapter -> T.Text -> IO ()
display ctx chapter frag = do
setWindowTitle ctx (chapterIndex chapter) (chapterTitle chapter)
WebKit.webViewLoadHtml (appWebView (ctxWidgets ctx)) (wrapHtml (chapterHtml chapter) frag) Nothing
showLoading :: Ctx -> ChapterRef -> IO ()
showLoading ctx ref = do
setWindowTitle ctx (refIndex ref) (refTitle ref)
WebKit.webViewLoadHtml (appWebView (ctxWidgets ctx)) (wrapHtml "<p style=\"opacity:0.5\">Loading…</p>" "") Nothing
setWindowTitle :: Ctx -> ChapterIndex -> T.Text -> IO ()
setWindowTitle ctx (ChapterIndex i) chapter = do
st <- readTVarIO (ctxState ctx)
let total = length (stRefs st)
title = T.pack (show (i + 1)) <> "/" <> T.pack (show total) <> " " <> stTitle st <> "" <> chapter
Gtk.set (appWindow (ctxWidgets ctx)) [#title := title]
setZoom :: Ctx -> Double -> IO ()
setZoom ctx level = do
atomically $ modifyTVar' (ctxState ctx) $ \s -> s {stZoom = level}
WebKit.webViewSetZoomLevel (appWebView (ctxWidgets ctx)) level
-- | Wrap a chapter's @\<body\>@ fragment in a full, styled HTML document
-- (WebKit renders bare fragments unreliably). If @frag@ names an anchor, a
-- small script scrolls it into view after load.
wrapHtml :: T.Text -> T.Text -> T.Text
wrapHtml body frag =
T.concat
[ "<!DOCTYPE html><html><head><meta charset=\"utf-8\">",
"<style>",
"body{max-width:42rem;margin:2rem auto;padding:0 1rem;",
"font-family:Georgia,serif;line-height:1.6;}",
"img{max-width:100%;height:auto;}",
"</style></head><body>",
body,
scrollScript frag,
"</body></html>"
]
where
scrollScript "" = ""
scrollScript f =
let safe = T.filter (\c -> c /= '"' && c /= '\\') f
in "<script>var e=document.getElementById(\"" <> safe <> "\");if(e)e.scrollIntoView();</script>"
--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------
-- | Schedule an action to run on the GTK main loop.
postGtk :: IO () -> IO ()
postGtk act = void $ GLib.idleAdd GLibConst.PRIORITY_DEFAULT (act >> pure False)
activate :: EpubEnv -> Gtk.Application -> IO ()
activate env app = do
stateTVar <- newTVarIO (initialState (bookTitle env) (bookFilePath env))
widgets <- buildLayout app
let ctx = Ctx stateTVar widgets (renderChapter env)
wireEvents ctx
loadBook env ctx
#present (appWindow widgets)
runApp :: EpubEnv -> IO ()
runApp env = do
app <- Gtk.new Gtk.Application [#applicationId := "com.svitak.reader", #flags := [Gio.ApplicationFlagsDefaultFlags]]
_ <- on app #activate (activate env app)
_ <- Gio.applicationRun app Nothing -- we're not working with CLI args here, so Nothing can be passed
_ <- Gio.applicationRun app Nothing
pure ()

View file

@ -88,18 +88,20 @@ test-suite svitak-test
type: exitcode-stdio-1.0
other-modules: EpubParser,
Persistence
Navigation,
Types
hs-source-dirs: test, app
build-depends: base,
base64-bytestring,
bytestring,
containers,
epub-metadata,
filepath,
mtl,
scalpel,
text,
zip-archive,
directory
zip-archive
default-language: Haskell2010

View file

@ -1,46 +1,74 @@
{-# LANGUAGE OverloadedStrings #-}
-- | Manual dump tool (not automated tests). Opens a real EPUB, prints the
-- resolved chapter list (rendering each on demand to show content sizes) and
-- the TOC tree:
--
-- > cabal run svitak-test -- path/to/book.epub
module Main (main) where
import qualified Data.Text as T
import EpubParser (openEpub)
import System.Environment (getArgs)
import Control.Monad.Reader (runReaderT)
import System.Exit (exitFailure)
import EpubParser (openEpub, resolveSpine, getChapter, EpubEnv)
-- Run this as: cabal run svitak-test -- "path/to/book.epub"
import Types
( BookInfo (..),
BookStructure (..),
Chapter (..),
ChapterIndex (..),
ChapterRef (..),
InternalPath (..),
TocEntry (..),
)
main :: IO ()
main = do
args <- getArgs
case args of
[path] -> runRawDump path
_ -> do
putStrLn "cabal run svitak-test -- \"path/to/book.epub\""
exitFailure
args <- getArgs
case args of
[path] -> run path
[path, n] -> dumpOne path (read n)
_ -> putStrLn "usage: svitak-test <book.epub> [chapter-index]" >> exitFailure
runRawDump :: FilePath -> IO ()
runRawDump path = do
putStrLn $ "Testing EPUB: " ++ path
result <- openEpub path
case result of
Left err -> do
putStrLn $ "Failed to open: " ++ err
exitFailure
Right env -> do
spine <- resolveSpineList env
putStrLn $ "Spine resolved. Found " ++ show (length spine) ++ " items."
mapM_ (dumpChapter env) spine
-- | Print the rendered HTML of a single chapter (for debugging image inlining).
dumpOne :: FilePath -> Int -> IO ()
dumpOne path n = do
Right env <- openEpub path
Right (BookStructure refs _) <- loadStructure env
Right ch <- renderChapter env (refs !! n)
putStrLn (T.unpack (T.take 800 (chapterHtml ch)))
resolveSpineList :: EpubEnv -> IO [FilePath]
resolveSpineList env = runReaderT resolveSpine env
run :: FilePath -> IO ()
run path = do
opened <- openEpub path
case opened of
Left err -> putStrLn ("open failed: " ++ err) >> exitFailure
Right env -> do
putStrLn ("Title: " ++ T.unpack (bookTitle env))
putStrLn ("Authors: " ++ show (map T.unpack (bookAuthors env)))
structure <- loadStructure env
case structure of
Left err -> putStrLn ("structure failed: " ++ err) >> exitFailure
Right (BookStructure refs toc) -> do
putStrLn ("\n== Chapters (" ++ show (length refs) ++ ") ==")
mapM_ (dumpChapter env) refs
putStrLn "\n== TOC tree =="
mapM_ (dumpToc 0) toc
dumpChapter :: EpubEnv -> FilePath -> IO ()
dumpChapter env filename = do
putStrLn $ "\n START OF FILE: " ++ filename
putStrLn (replicate 40 '-')
dumpChapter :: (BookInfo a) => a -> ChapterRef -> IO ()
dumpChapter env ref = do
rendered <- renderChapter env ref
let size = case rendered of
Left err -> "ERR " ++ err
Right c -> show (T.length (chapterHtml c)) ++ " chars"
putStrLn (" [" ++ show idx ++ "] " ++ T.unpack (refTitle ref) ++ " (" ++ path ++ ", " ++ size ++ ")")
where
ChapterIndex idx = refIndex ref
InternalPath path = refPath ref
tags <- runReaderT (getChapter filename) env
mapM_ print tags
putStrLn (replicate 40 '-')
putStrLn $ "END OF FILE: " ++ filename
dumpToc :: Int -> TocEntry -> IO ()
dumpToc depth t = do
putStrLn (replicate (depth * 2) ' ' ++ "- " ++ T.unpack (tocLabel t) ++ " -> " ++ target ++ frag)
mapM_ (dumpToc (depth + 1)) (tocChildren t)
where
InternalPath target = tocTarget t
frag = if T.null (tocFragment t) then "" else "#" ++ T.unpack (tocFragment t)