iOS-小说阅读器功能拆分之含有多级章节的目录情况处理

2,138 阅读5分钟

上一篇 WLReader整体介绍了阅读器实现效果,以及github的源码,附带了使用方式

之后会出几篇小说阅读器功能拆分后的一些细节点如何来实现,大致包括:

  • 目录,分章节
  • 长按选中文本
  • 笔记划线定位
  • 如何处理每一页首行缩进
  • 下载与解析联动
  • 如何处理标签中的图片宽高
  • 链接和图片的点击事件

以上大致涵盖了阅读器中几个比较重要的点,今天就先来分享下对于章节中分小章节的情况,先来表一表这种背景下的epub文件的结构:

epub结构

epub解析的几个主要点:

  • content.opf
  • toc.ncx

书籍的相关信息都在这两个文件里了,笔者在做这块的时候发现,市面上的许多epub文件,虽然都含有这两个文件,但是文件的信息部分却相差很大,比较省事的是,toc.ncx 包含所有的章节信息,比如下面:

toc.ncx-1

这个解析出来就是对应的章节列表,很标准

它对应的 content.opf对应的 spin也是标准的

content.opt-1

可以看到,这两个文件无论是哪个,解析出来的章节列表和内容都可以一一对得上

但是 也有不按常理出牌的,比如比较极端的,一整本书只有一个章节,这个也还好处理,如下面的:

toc.ncx-2

它对应的content.opf如下:

content.opf-2

上面的两种情况都可以按章节列表与章节对应来处理,分页或者章节高亮上都没有问题

那么有一种情况问题就来了,就是章节中有小章节的情况,比如下面:

截屏2024-06-24 16.22.17.png

那么它对应的 toc.ncxconent.opf是什么样的呢?

先来看一下解析的结构表:

截屏2024-06-24 16.23.29.png

toc.ncx-3

它的章节中是含有children子章节的,仔细看会发现,子章节并不是一个子章节对应一个文件,而是多个子章节共用同一个文件,那么分子章节的分页的时候,就需要针对这个做特殊处理,在说到这块之前,再看一下对应的 content.opf文件,从这里我们可以总结出一个兼容性比较强的取章节内容和对应的章节列表的方式

截屏2024-06-24 16.26.55.png

单看截图看不出来东西,有兴趣的可以解压文件后对这两个文件认真仔细的查看

笔者一开始都是根据 toc.ncx 取章节内容以及章节列表,结果发现有的书解析后章节列表错乱,且章节内容会出现遗漏的部分,研究了多个epub文件之后,笔者才发现,原来toc.ncx并不完全包含所有的章节内容,经分析后找到,在content.opf下的spin文件才是实际涵盖了所有的章节内容,也就是说,整本书分章节的地方,是需要用spin解析后的数据。

下面是分章节的代码:

// 获取实际的章节,它包含所有的内容

     private func chapterFromEpub(epub: WLEpubBook) {

        // 获取章节

         chapters.removeAll()

         let filterChapters = epub.spine.spineReferences.filter { $0.linear}

         for (index, spin) in filterChapters.enumerated() {

             let chapter = WLBookChapter()

             let resource = spin.resource

             chapter.chapterId = resource.id

             chapter.chapterIndex = index

             chapter.href = resource.href

             chapter.fullHref = URL(fileURLWithPath: resource.fullHref)

             chapter.bookType = bookType

             chapter.chapterSrc = resource.href

             chapters.append(chapter)

         }

    }

要注意下,需要保留 href,这个在后面子章节对应上有很大作用

那么分章节和章节列表不应该是一样的吗?尝试后,如果直接使用分章节(章节内容数组)的章节内容来作为章节列表的话,会显示不出来子章节且多出来一些非章节目录部分,这个也可以看出,章节列表和章节内容并非是一一完全合得上的,那么问题来了,如何才能合理的处理二者的联动部分呢?这也是下面着重要说的

xhtml源文件中,子章节都会有各自独有的id标识

比如:

<p class="title1" id="mllj6-1">不要批评、责怪或抱怨</p>

<p class="title1" id="mllj6-2">发自内心地赞赏别人</p>

所以分子章节的思路就是,根据这个 id 匹配,获取子章节起始部分在章节内容中的位置,但是没有直接的方式,所以我们在处理内容的时候,需要按一下几个步骤:

  • 解析 xhtml源文件
  • 在原内容中插入所有的子章节的id做标识用
  • 生成富文本后,根据之前的id,匹配出所有的子章节起始的location,存储成一个以idkeylocationvaluemap
  • 分页完成后,需要根据上一步生成的map表,去查出子章节在章节中所处的页码pageIndex,并且需要计算出每一个子章节所处的页码范围

我们从第二步开始:

id做标识
private func insertFragmentIDForHtml(html:String) -> String! {

        var originHtml = html

        let pattern = #"<p\s+[^>]*id="([^"]+)"[^>]*>"#

        do {

            let regex = try NSRegularExpression(pattern: pattern, options: [])

                    

            // 查找并替换每个带有 id 属性的 <p> 标签

            let modifiedText = regex.stringByReplacingMatches(in: originHtml, options: [],

                                                              range: NSRange(originHtml.startIndex..<originHtml.endIndex, in: originHtml),

                                                              withTemplate: "${id=$1}$0")

            

            originHtml = modifiedText

            

        } catch {

            print("Invalid regex: \(error.localizedDescription)")

        }

        return originHtml

    }
获取子章节的location
// 获取fragmentID所在章节分页的location

    private func getFragmentIDMarkLocation() {

        let mutableStr = NSMutableString(string: chapterContentAttr.string)

        let mutableAttString = chapterContentAttr.mutableCopy() as! NSMutableAttributedString

        var locationWithPageIdMapping: [String: Int] = [:]

        

        let idMarkRegex = "\\$\\{id=([^}]+)\\}"

        var offset = 0

        do {

            let regex = try NSRegularExpression(pattern: idMarkRegex, options: [])

            var range = NSRange(location: 0, length: mutableStr.length)

            

            while let match = regex.firstMatch(in: mutableStr as String, options: [], range: range) {

                // 获取整个匹配内容

                let fullMatch = (mutableStr.substring(with: match.range) as NSString)

                // 提取捕获组中的id值

                let idValue = fullMatch.replacingOccurrences(of: "\\$\\{id=", with: "", options: .regularExpression, range: NSMakeRange(0, fullMatch.length)).replacingOccurrences(of: "\\}", with: "", options: .regularExpression)

                

                // 计算并存储匹配位置

                let adjustedLocation = match.range.location + offset

                locationWithPageIdMapping[idValue] = adjustedLocation

                

                // 可以修改的匹配范围变量

                var matchRange = match.range

                

                // 检查并删除匹配范围之前的多余换行符

                if match.range.location > 0 {

                    var newlineCount = 0

                    while match.range.location - newlineCount - 1 >= 0 && mutableStr.character(at: match.range.location - newlineCount - 1) == 10 {

                        newlineCount += 1

                    }

                    if newlineCount > 0 {

                        let removeRange = NSRange(location: match.range.location - newlineCount, length: newlineCount)

                        mutableStr.deleteCharacters(in: removeRange)

                        mutableAttString.deleteCharacters(in: removeRange)

                        offset -= newlineCount

                        

                        // 更新匹配位置

                        matchRange.location -= newlineCount

                    }

                }

                

                // 获取需要删除范围内的所有属性

                let attributes = mutableAttString.attributes(at: matchRange.location, longestEffectiveRange: nil, in: matchRange)

  


                // 移除这些属性

                for attribute in attributes {

                    mutableAttString.removeAttribute(attribute.key, range: matchRange)

                }

                

                mutableStr.deleteCharacters(in: matchRange)

                mutableAttString.deleteCharacters(in: matchRange)

                

                offset -= matchRange.length

                

                range = NSRange(location: matchRange.location, length: mutableStr.length - matchRange.location)

            }

            

            chapterContentAttr = mutableAttString

            locationWithFragmentIDMap = locationWithPageIdMapping

        } catch {

            print("Invalid regex: \(error.localizedDescription)")

        }

    }
  • 根据正则匹配出来所有 ${id=xxx}这样的标识
  • 找出对应的id
  • 算出来location并存储
  • 删除匹配范围之前多余的换行符,这个是为了避免显示部分顶部有太多空白
  • 找出位置之后,需要移除之前添加的id标识,将富文本还原
找出章节列表每一个元素对应的chapterIndexpageChapterIndexpageIndexRange
// 获取章节目录中的页码

    private func getCataloguePageIndex(bookModel:WLBookModel) {

        getCataloguePageIndex(catalogues: bookModel.catalogues)

        for catalogue in bookModel.catalogues {

            if let children = catalogue.children, children.count > 0  {

                getCataloguePageRange(catalogues: children)

            }

        }

    }

    // 获取章节列表每一个章节对应的页面Index

    private func getCataloguePageIndex(catalogues:[WLBookCatalogueModel]) {

        for catalogue in catalogues {

            if let child = catalogue.children, child.count > 0 {

                getCataloguePageIndex(catalogues: child)

            }else {

                let fragmentID = catalogue.fragmentID

                if let fragmentID = fragmentID {

                    let location = self.locationWithFragmentIDMap[fragmentID]

                    if let location = location {

                        for page in self.pages {

                            if location >= page.pageStartLocation {

                                catalogue.pageChapterIndex = page.page

                            }

                        }

                    }

                }

            }

        }

    }
// MARK - 获取章节列表章节对应的页面范围

    private func getCataloguePageRange(catalogues:[WLBookCatalogueModel]) {

        var previous:WLBookCatalogueModel? = catalogues.first

        for item in catalogues {

            getCataloguePageRange(previous: previous, current: item)

            previous = item

        }

        if previous == catalogues.last, let previous = previous { // 最后一个

            previous.pageIndexRange = NSRange(location: previous.pageChapterIndex, length: self.pages.count - previous.pageChapterIndex - 1)

        }

    }

    private func getCataloguePageRange(previous:WLBookCatalogueModel?, current:WLBookCatalogueModel?) {

        if previous == current {

            return

        }

        if let previous = previous, let current = current {

            previous.pageIndexRange = NSRange(location: previous.pageChapterIndex, length: current.pageChapterIndex - previous.pageChapterIndex - 1)

        }

    }

到这里对应的章节列表数据就弄好了,但是在显示的时候还需要对章节目录数据做进一步处理,需要将对应的children展开,平铺到章节列表数据中

// 处理目录数据

    private func generateCatalogues(items:[WLBookCatalogueModel]!) {

        for (index, item) in items.enumerated() {

            self.catalogues.append(item)

            if let child = item.children {

                generateCatalogues(items: child)

            }

        }

    }

在显示的时候高亮部分的处理如下,需要区分一级和二级章节:

if catalogueModel.level == 0 {

            cell.isReadingCurrentChapter = bookModel.chapterIndex == catalogues[indexPath.row].chapterIndex

        }else {            

            cell.isReadingCurrentChapter = (bookModel.chapterIndex == catalogueModel.chapterIndex &&

                                            bookModel.pageIndex >= catalogueModel.pageIndexRange.location &&

                                            bookModel.pageIndex <= catalogueModel.pageIndexRange.location + catalogueModel.pageIndexRange.length)

        }

点击章节列表的item事件,需要跳转到对应的章节的第几页,前面已经计算完毕,这里直接用就行:

// MARK - WLChapterListViewDelegate

    func chapterListViewClickChapter(chapterListView: WLChapterListView, catalogueModel: WLBookCatalogueModel) {

        // 将章节列表和cover都去掉

        showChapterListView(show: false)

        container.showCover(show: false)

        // 先看下点击的是否是二级标题

        if catalogueModel.level == 0 {

            if bookModel.chapterIndex == catalogueModel.chapterIndex {

                return

            }

            goToChapter(chapterIndex: catalogueModel.chapterIndex, toPage: 0)

            return

        }

        // 二级

        if let pageIndex = catalogueModel.pageChapterIndex {

            if catalogueModel.chapterIndex == bookModel.chapterIndex {

                if pageIndex == bookModel.pageIndex {

                    return

                }

                goToChapter(chapterIndex: catalogueModel.chapterIndex, toPage: pageIndex)

                return

            }

            goToChapter(chapterIndex: catalogueModel.chapterIndex, toPage: 0)

        }else {

            if catalogueModel.chapterIndex == bookModel.chapterIndex {

                

                return

            }

            goToChapter(chapterIndex: catalogueModel.chapterIndex, toPage: 0)

        }

    }

思路大致如此,做阅读器细节点太多了,后续会陆续更新一些细节,有兴趣的可以一起探讨,源码地址在阅读器源码