From 2011d638b0d80639d5919edc3f834d15e387f849 Mon Sep 17 00:00:00 2001 From: Marko Andjelic Date: Fri, 26 Jun 2026 03:41:20 +0100 Subject: [PATCH] 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 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 ( 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 ' dumps one rendered chapter. cabal test stanza fixed to list all needed modules/deps. --- app/EpubParser.hs | 404 +++++++++++++++++++++++++++------------------- app/Navigation.hs | 117 +++++++------- app/State.hs | 51 ++---- app/Types.hs | 161 ++++++++++-------- app/UI.hs | 338 +++++++++++++++++++++++++++++--------- svitak.cabal | 8 +- test/Spec.hs | 98 +++++++---- 7 files changed, 744 insertions(+), 433 deletions(-) diff --git a/app/EpubParser.hs b/app/EpubParser.hs index 1260cb8..47f7dac 100644 --- a/app/EpubParser.hs +++ b/app/EpubParser.hs @@ -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;" -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 @\@. +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 @\@ 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 @\@, 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 @@ @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. +-- @@ becomes @@ — 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:;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) diff --git a/app/Navigation.hs b/app/Navigation.hs index 8d32dbe..43894e7 100644 --- a/app/Navigation.hs +++ b/app/Navigation.hs @@ -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 @\@s. +-- * EPUB 3 — a nav document with nested @\@\/@\@ 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 @\@ of nested @\@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 @\