电子书解析
思路:上传电子书的时候使用了multer中间件来完成上传过程,上传之后会在req产生一个file对象,这个file对象表示一个数组来代表文件的序列,file对象下包含了文件对象,文件对象里包含了文件名,文件路径,文件资源类型等等,那到这些信息之后就可以通过这些信息生成一个book对象,这里的book对象就是所说的电子书对象,然后通过book对象来完成解析的过程
models/Book.js
这里的book就代表一本电子书,他必须要给我们提供一些能力,这些能力包含从文件当中去创建对象,还有一种是在编辑的时候,需要能够根据表单的数据把它也变成一个book对象
变成book对象以后有什么好处呢?通过解析成book对象以后,就可以写一些方法比如parse方法对book对象进行解析,就可以解析到里面的一些细节信息,比如language,title ,creator等,
可以解析出电子书的目录,同时能将book对象转换成json格式(可以直接拿给前端使用),可以转换成数据库字段名快速的生成一些sql语句,所以book对象对应我们来开发整个电子书解析部分是至关重要的,所以,电子书解析很大一部分都是在编写book对象。
电子书book对象开发
传入file表示刚上传了一个电子书的文件,如果传入一个data表示更新或者插入电子书数据,data表示向数据库中插入数据,file主要用于解析电子书数据
router/book.js调用
知道file对象的内容了以后,就可以对他进行解析了
mimetype可以给他一个默认类型
我们需要给文件改个名字,因为发现返回的file.path路径是没有后缀名的,去识别这个文件的时候会有些麻烦(suffix)
生成文件的下载路径 定义url constant.js
这里改了一下UPLOAD_PATH 不用两个反斜杠了 一个‘/’也可以
这里发现了一个上古时期的bug UPLOAD_PATH后面不应该有\book的
// 生成文件下载路径 通过这个下载路径就可以快速的下载到电子书
const url=`${
UPLOAD_URL}/book/${
filename}${
suffix}`
解压后的文件夹同理
const {
MIME_TYPE_EPUB,UPLOAD_URL,UPLOAD_PATH}=require('../utils/constant')
class Book{
constructor(file,data){
if(file){
this.createBookFromFile(file)
}else{
this.createBookFromData(data)
}
}
createBookFromFile(file){
// console.log("createBookFromFile",file);
const{
destination,
filename,
mimetype=MIME_TYPE_EPUB,
path
}=file
// 电子书的文件后缀名
const suffix=mimetype===MIME_TYPE_EPUB?'.epub':''
// 电子书原有路径
const oldBookPath=path // 原有路径
// 电子书新路径
const bookPath=`${
destination}\\${
filename}${
suffix}` //新路径
// 生成文件下载路径 通过这个下载路径就可以快速的下载到电子书
// 电子书的下载URL
const url=`${
UPLOAD_URL}/book/${
filename}${
suffix}`
// 生成电子书解压文件夹 文件夹以文件名命名
// 电子书解压后的文件夹路径
const unzipPath=`${
UPLOAD_PATH}\\unzip\\${
filename}`
// 这个url路径会在电子书阅读的时候使用到它
// 电子书解压后的文件夹URL
const unzipUrl=`${
UPLOAD_URL}/unzip/${
filename}`
}
createBookFromData(){
}
}
module.exports=Book
接下来可以创建一下电子书的解压文件夹
if(!fs.existsSync(unzipPath)){
// 不存在的话迭代创建文件夹
fs.mkdirSync(unzipPath,{
recursive:true})
}
接下来解压以后的文件就会丢到这个路径下面
对文件进行重命名
// 判断当前电子书是否存在 如果存在且新的电子书不存在的情况下
// 调用rename对文件夹重命名的方法,把oldBookPath和bookPath传入实现重命名
if(fs.existsSync(oldBookPath)){
fs.renameSync(oldBookPath,bookPath)
}
接下来根据前端所需要的一些字段定义book对象的一些属性
this.filename=filename // 无后缀的文件名
// 写相对路径,为了兼容不同的场景 因为在服务端和客户端他的绝对路径是不一样
this.path=`/book/${
filename}${
suffix}` // epub文件相对路径
this.filePath=this.path // 起一个别名
this.unzipPath=`/unzip/${
filename}` // 解压后相对路径
this.url=url // epub文件下载链接
this.title='' // 标题或书名,解析后生成
this.author=''
this.publisher='' // 出版社
this.contents=[] // 目录
this.cover='' // 封面图片url
this.category=-1 // 分类id
this.categoryText='' // 分类名称
this.language='' // 语种
this.unzipUrl=unzipUrl // 解压后的文件夹链接
this.originalname=originalname // 原始名
看一下结果(这里两个反斜杠的都应该改成/)
电子书解析库epub库
epubjs库是用于浏览器场景,脱离浏览器是无法工作的,因为他主要是在浏览器场景下对浏览器进行渲染
这里的epub库是在node环境下进行使用的
https://github.com/julien-c/epub/blob/master/epub.js
因为需要对他的代码进行修改,所以拷贝一下集成到项目中,不是通过npm包安装的方式
utils/epub.js
安装adm-zip xml2js
Epub类提供了一个parse方法
实际去解析的时候就用到了parse方法
看一下使用方法 是使用event来实现的
传入之后,就用了一个回调的方法
后面的function是解析成功之后的回调
什么时候开始手动解析呢 需要调用epub.parse()
通过epub实例调用getChapter方法,里面再去调用一个回调
作者,标题这些信息可以去metadata里面获取
解析成功之后可以通过epub.metadata拿到
flow是整个电子书渲染的次序
getChapter获取章节(传入章节id) 获取章节对应的文本
getChapterRaw表示获得的原始文本,也就是一个html格式的文件
getImage 传入图片id 拿到图片实际的内容
getFile 传入css的id,拿到css的文件
因为存在大量的回调情况,后面会对他进行改造
电子书解析方法上
model/Book.js引入epub库
新增一个parse方法 我们给Book增加了很多属性,但是有很多都是默认值,在parse里解析,之后再给填充上
parse(){
return new Promise((resolve,reject)=>{
const bookPath=`${
UPLOAD_PATH}${
this.filePath}1`
// 如果不存在文件路径 抛出错误
if(!fs.existsSync(bookPath)){
reject (new Error('电子书不存在'))
}
})
}
测试一下
router/book.js
为了验证,把路径随便改一下
前端一直卡在这里 是因为没有返回内容,可以用到boom来快速生成异常对象
.catch(err => {
console.log("upload", err);
// 告诉前端发生了解析异常
next(boom.badImplementation(err))
})
就是说在Book对象中使用reject包装的error 会通过路由返回给next 然后被自定义异常捕获再返回给前端,前端再进行相应的处理,这样服务端抛出的异常就可以被前端捕获到了
电子书解析方法下
看到调用parse方法之后 他会调用一个open方法
在model/Book.js:
消费
reject后
接着会到自定义异常处理
测试
把bookPath改回来
打印出来epub.metadata
需要解析出来metadata里的信息
打印book
epub对象:
containerFile :epub解析的第一个文件 根据这个文件找content.opf
rootFile:就是content.opf的位置,因为阅读电子书的时候其实就是要解析content.opf
只要能找到content.opf 那么后面的流程就好办了
manifest:资源文件 通过资源文件就可以找到封面图片
toc:目录
book对象打印
获取封面:通过epub库提供的getImage方法
这个方法需要传入两个参数,一个是id一个是callback
id就是封面图片对应的id
我们就是要把href里的图片拷贝到nginx目录下的img文件夹下 这样的话我们就可以拿到url链接,拿到这个链接就可以作为封面图片的链接
分析一下getImage方法的源码
getImage(id, callback) {
// 到manifest找链接
if (this.manifest[id]) {
// 判断media-type 如果存在会把前面的六个字符截取与image/对比,不相等的话抛出异常 在callback中传入一个error对象
if ((this.manifest[id]['media-type'] || "").toLowerCase().trim().substr(0, 6) != "image/") {
return callback(new Error("Invalid mime type for image"));
}
// 如果是图片的话就调用getFile 把id和callback传入
this.getFile(id, callback);
} else {
callback(new Error("File not found"));
}
};
看一下getFile如何实现的
来使用一下getImage方法 其实可以对库做一些翻新,用.then或者async await更好, 这里先用回调
这样data数据已经获取到了 (读取到了内存当中 还不在磁盘当中)
suffix根据mimetype来获取
写入文件
打卡Book.js代码
const {
MIME_TYPE_EPUB, UPLOAD_URL, UPLOAD_PATH } = require('../utils/constant')
const fs = require('fs')
const Epub = require('../utils/epub')
class Book {
constructor(file, data) {
if (file) {
this.createBookFromFile(file)
} else {
this.createBookFromData(data)
}
}
createBookFromFile(file) {
console.log("createBookFromFile", file);
const {
destination,
filename,
mimetype = MIME_TYPE_EPUB,
path,
originalname
} = file
// 电子书的文件后缀名
const suffix = mimetype === MIME_TYPE_EPUB ? '.epub' : ''
// 电子书原有路径
const oldBookPath = path // 原有路径
// 电子书新路径
const bookPath = `${
destination}/${
filename}${
suffix}` //新路径
// 生成文件下载路径 通过这个下载路径就可以快速的下载到电子书
// 电子书的下载URL
const url = `${
UPLOAD_URL}/book/${
filename}${
suffix}`
// 生成电子书解压文件夹 文件夹以文件名命名
// 电子书解压后的文件夹路径
const unzipPath = `${
UPLOAD_PATH}/unzip/${
filename}`
// 这个url路径会在电子书阅读的时候使用到它
// 电子书解压后的文件夹URL
const unzipUrl = `${
UPLOAD_URL}/unzip/${
filename}`
// 如果不存在unzipPath,就去创建
if (!fs.existsSync(unzipPath)) {
// 不存在的话迭代创建文件夹
fs.mkdirSync(unzipPath, {
recursive: true })
}
// 判断当前电子书是否存在 如果存在且新的电子书不存在的情况下
// 调用rename对文件夹重命名的方法,把oldBookPath和bookPath传入实现重命名
if (fs.existsSync(oldBookPath)) {
fs.renameSync(oldBookPath, bookPath)
}
this.filename = filename // 无后缀的文件名
// 写相对路径,为了兼容不同的场景 因为在服务端和客户端他的绝对路径是不一样
this.path = `/book/${
filename}${
suffix}` // epub文件相对路径
this.filePath = this.path // 起一个别名 电子书相对路径
this.unzipPath = `/unzip/${
filename}` // 解压后相对路径
this.url = url // epub文件下载链接
this.title = '' // 标题或书名,解析后生成
this.author = ''
this.publisher = '' // 出版社
this.contents = [] // 目录
this.cover = '' // 封面图片url
this.coverPath=''
this.category = -1 // 分类id
this.categoryText = '' // 分类名称
this.language = '' // 语种
this.unzipUrl = unzipUrl // 解压后的文件夹链接
this.originalname = originalname // 原始名
}
createBookFromData() {
}
parse() {
return new Promise((resolve, reject) => {
const bookPath = `${
UPLOAD_PATH}${
this.filePath}`
// 如果不存在文件路径 抛出错误
if (!fs.existsSync(bookPath)) {
reject(new Error('电子书不存在'))
}
// 创建个实例
const epub = new Epub(bookPath)
// error回调,判断解析过程中有没有出现异常
epub.on('error', err => {
reject(err)
})
// end事件表示电子书成功解析
epub.on('end', err => {
if (err) {
reject(err)
} else {
// console.log("epub+ ", epub.manifest);
const {
language,
creator,
creatorFileAs,
title,
cover,
publisher
} = epub.metadata
if (!title) {
reject(new Error('图书标记为空'))
} else {
this.title = title
this.language = language || 'en' // 不存在默认为英文
this.author = creator || creatorFileAs || 'unknown'
this.publisher = publisher || 'unknown'
this.rootFile = epub.rootFile
const handleGetImage = (err, file, mimetype) =>{
console.log(err, file, mimetype);
if (err) {
reject(err)
} else {
// 需要整个电子书解析完之后再调用resolve,而不是直接调完getImage就resolve,
//因为getImage可能出错,调完resolve再调reject逻辑就会出现问题
const suffix = mimetype.split('/')[1]
const coverPath = `${
UPLOAD_PATH}/img/${
this.filename}.${
suffix}`
const coverUrl = `${
UPLOAD_URL}/img/${
this.filename}.${
suffix}`
// 把buffer写入到磁盘当中
console.log(coverPath);
fs.writeFileSync(coverPath,file,'binary')
this.coverPath=`/img/${
this.filename}.${
suffix}`
this.cover=coverUrl
resolve(this)
}
}
epub.getImage(cover, handleGetImage)
// resolve(this) 不要在这里写
}
}
})
epub.parse()
})
}
}
module.exports = Book
封面图片解析优化
有的电子书用这种方法是获取不到封面图片的
看一下这个错误出现在哪里
打印一下cover
解压出来分析一下
打开package.opf
metadata里没有标签是关于cover的 说明没有办法获得封面图片的资源id了
在manifest里找找
能看到封面的资源文件,但是是xhtml的类型,说明他是章节的内容,并不是图片 图片应该是image开头的
这个才是封面图片
他是在image路径下978开头的文件
所以封面还有一种查询方式 就是读取item下面的properties 如果为cover-image 表示是封面图片 可以把这个图片的href获取到,然后来找到它的资源文件并且从epub当中解压出来保存到本地
需要对epub.js的getImage方法改造
如果manifest没有办法从cover下获取的时候需要改进一下这里的逻辑
大致框架
如何获取coverId
const coverId=Object.keys(this.manifest).find(key=>{
//注意这里不是花括号
// console.log(key,this.manifest[key]);
this.manifest[key].properties==='cover-image'
})
getImage(id, callback) {
// 到manifest找链接
if (this.manifest[id]) {
// 判断media-type 如果存在会把前面的六个字符截取与image/对比,不相等的话抛出异常 在callback中传入一个error对象
if ((this.manifest[id]['media-type'] || "").toLowerCase().trim().substr(0, 6) != "image/") {
return callback(new Error("Invalid mime type for image"));
}
// 如果是图片的话就调用getFile 把id和callback传入
this.getFile(id, callback);
} else {
// 传入的id无法用 需要获取到coverId 判断coverId是否存在
// 这样就把符合条件的key返回
const coverId = Object.keys(this.manifest).find(key => (
// console.log(key,this.manifest[key]);
this.manifest[key].properties === 'cover-image'
))
console.log("coverId", coverId);
if (coverId) {
this.getFile(coverId, callback)
} else {
callback(new Error("File not found"));
}
}
};
接下来开发一个比较有难度的点–解析电子书目录
在epub库并没有提供解决方案,manifest目录虽然有很多的资源文件,但是并没有形成一个顺序,我们还要确定目录的层级关系
目录解析原理和电子书解压
目录解析原理
先从spin标签下面获取toc属性 (目录的资源id)
之后在manifest里找
打开toc.ncx
navMap:导航
里面都是目录,目录可能会出现嵌套的情况
1.对电子书文件进行解压
解压后放unzip的文件夹下
通过之前getFile方法,能够直接获取到电子书文件,但是我们选择先对他进行解压,这样读取的效率会更高一些
来到自己编写的Book类中,
编写unzip方法
unzip(){
const AdmZip=require('adm-zip')
const zip=new AdmZip(Book.genPath(this.path))
// zip对象的api extractAllTo() 含义是将路径下的文件进行解压,
// 解压以后把它放到一个新的路径下 第二个参数:是否进行覆盖
zip.extractAllTo(Book.genPath(this.unzipPath),true)
}
// 生成静态方法,获得绝对路径
static genPath(path){
if(!path.startsWith('/')){
path=`/${
path}`
}
return `${
UPLOAD_PATH}${
path}`
}
}
解压出来以后就可以对他进行解析了
unzip方法是个同步方法
unzip过后就可以定义一个parseContents了 传入epub对象 因为要去toc spin里面去找toc属性
parseContents(epub){
function getNcxFilePath(){
const spine=epub&&epub.spine
console.log("spine",spine);
}
getNcxFilePath()
}
打印出spine
可以看到spine下面有个toc属性
可以找到toc对应的id/直接拿href也可以
如果没有href的时候,就找id->找manifest
parseContents(epub){
function getNcxFilePath(){
const spine=epub&&epub.spine
const manifest=epub&&epub.manifest
const ncx=spine.toc&&spine.toc.href
const id=spine.toc&&spine.toc.id
console.log("spine", spine.toc,ncx,id,manifest[id].href);
if(ncx){
return ncx
}else{
// 这个一定会存在的,因为这是电子书的目录
return manifest[id].href
}
}
getNcxFilePath()
}
可以发现两种方法都可以获取到目录
之后来拿到路径
const ncxFilePath=getNcxFilePath()
这样拿的是相对路径 需要把它拼成绝对路径
const ncxFilePath=Book.genPath(getNcxFilePath())
这样还是不对,需要加上unzipPath
const ncxFilePath=Book.genPath(`${
this.unzipPath}/${
getNcxFilePath()}`)
console.log(ncxFilePath);
还要做一件事情,判断这个路径是否存在,如果不存在需要抛出异常
if(fs.existsSync(ncxFilePath)){
}else{
throw new Error('目录对应的资源文件不存在')
}
在这里catch到
最终前端会拿到错误信息
试验一下
const ncxFilePath=Book.genPath(`${
this.unzipPath}/${
getNcxFilePath()+1}`)
电子书标准目录解析
打开toc.ncx
在ncx对象下面有个navMap
navMap下面每一个navPoint都是一个目录选项
navLabel:具体的目录内容
content :src 目录的路径,playOrder:目录顺序
目录可能存在嵌套,我们还需要对二级目录进行识别,所以需要有一个迭代的方法实现目录的识别(难点)
Book.js首先引用xml2js库
https://www.npmjs.com/package/xml2js
我们要取的是ncx下面的navMap属性
打印一下navMap
技巧 :看一下详细信息
字符串粘到json.cn里面
目录结构
返回的结果他给包裹在一个数组中了,如果不希望包裹在数组中,可以加个参数,
xml2js(xml,{
explicitArray:false,
ignoreAttrs:false
},function(err,result){
if(err){
reject(err)
}else{
console.log(result)
const navMap=result.ncx.navMap
console.log(JSON.stringify(navMap));
}
})
现在的结构
增加findParent方法 因为这是单级的目录,所以返回一样的数组,以后会完善
function findParent(array){
return array.map(item=>{
return item
})
}
如果是有子目录的话,是一个树状结构,树状结构是不利于前端展示的
所以需要把树状结构改成一维的结构 现在还没有这个场景,但是还是先把方法建好
navMap.navPoint=findParent(avMap.navPoint)
const newNavMap=flatten(navMap.navPoint)
newNavMap是对navMap做的一个浅拷贝
function findParent(array){
return array.map(item=>{
return item
})
}
function flatten(array){
return [].concat(...array.map(item=>{
return item
}))
}
newNavMap是复制了一份数组
epub.flow:展示顺序
epub.flow.forEach((chapter,index)=>{
if(index+1>newNavMap.length){
// flow里面的信息超过目录信息 就return
return
}else{
// 没有超过的时候
// 拿到目录信息
const nav=newNavMap[index]
// 增加个属性(章节url)
chapter.text=`${
UPLOAD_URL}/unzip/${
fileName}/${
chapter.href}`
console.log(chapter.text);
}
})
console.log(epub.flow);
}else{
reject('目录解析失败,目录树为0')
}
有个问题,直接用epub.flow不行吗
其实用epub.flow是有一些隐含的坑的
有的电子书是没有order和level的,不精确
所以从navMap中获取比较正宗的目录信息
在继续向chapter里面加属性
if (nav && nav.navLabel) {
chapter.label = nav.navLabel.text || ''
} else {
chapter.label = ''
}
chapter.navId=nav['$'].id
chapter.fileName=fileName
chapter.order=index+1
chapters.push(chapter)
console.log(chapter.text);
嵌套目录解析
在findParent里做一些文章
level 默认为0 下一级为1 这样返回给前端的时候就可以根据level做缩进
传入3个参数 array,level=0,pid=0
navPoint里面是没有level字段的,可以给他加个level字段
function findParent(array, level = 0, pid = '') {
// 三个场景,一:没有包含navPoint:直接复值level,pid
// 二:存在navPoint同时navPoint又是数组 说明下面包含子目录 进行迭代
// 三:navPoint可能不是数组而是个对象的时候(只有一个目录),直接赋值
return array.map(item => {
item.level = level
item.pid = pid
// 说明存在子目录
if (item.navPoint && item.navPoint.length) {
item.navPoint = findParent(item.navPoint, level + 1, item['$'].id)
} else if (item.navPoint) {
item.navPoint.level = level + 1
item.navPoint.pid = item['$'].id
}
return item
})
}
flatten方法:将navPoint数组变成一个扁平状态
配合这里
如果不变扁平那newNavMap的长度一定小于index+1(flow)
flatten方法
function flatten(array) {
return [].concat(...array.map(item => {
// 如果包含的是数组
if(item.navPoint&&item.navPoint.length>0){
// 合并
return [].concat(item,...flatten(item.navPoint))
}else if(item.navPoint){
// 如果是个对象
return [].concat(item,item.navPoint)
}
return item
}))
}
resolve reject
可以把book返回给前端
new Result(book,‘上传成功’).success(res)