You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
363 lines
16 KiB
CoffeeScript
363 lines
16 KiB
CoffeeScript
path = require "path"
|
|
fs = require "fs"
|
|
Q = require "q"
|
|
_ = require "underscore"
|
|
uslug = require "uslug"
|
|
ejs = require "ejs"
|
|
cheerio = require "cheerio"
|
|
entities = require "entities"
|
|
request = require "superagent"
|
|
fsextra = require "fs-extra"
|
|
removeDiacritics = require("diacritics").remove
|
|
mime = require "mime"
|
|
archiver = require "archiver"
|
|
|
|
# provides rm -rf for deleting temp directory across various platforms.
|
|
rimraf = require "rimraf"
|
|
|
|
uuid = ->
|
|
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace /[xy]/g, (c)->
|
|
r = Math.random()*16|0
|
|
return (if c is 'x' then r else r&0x3|0x8).toString(16)
|
|
|
|
class EPub
|
|
constructor: (options, output)->
|
|
@options = options
|
|
self = @
|
|
@defer = new Q.defer()
|
|
|
|
if output
|
|
@options.output = output
|
|
|
|
if not @options.output
|
|
console.error(new Error("No Output Path"))
|
|
@defer.reject(new Error("No output path"))
|
|
return
|
|
|
|
if not options.title or not options.content
|
|
console.error(new Error("Title and content are both required"))
|
|
@defer.reject(new Error("Title and content are both required"))
|
|
return
|
|
|
|
@options = _.extend {
|
|
description: options.title
|
|
publisher: "anonymous"
|
|
author: ["anonymous"]
|
|
tocTitle: "Table Of Contents"
|
|
appendChapterTitles: true
|
|
date: new Date().toISOString()
|
|
lang: "en"
|
|
fonts: []
|
|
customOpfTemplatePath: null
|
|
customNcxTocTemplatePath: null
|
|
customHtmlTocTemplatePath: null
|
|
version: 3
|
|
}, options
|
|
|
|
if @options.version is 2
|
|
@options.docHeader = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="#{self.options.lang}">
|
|
"""
|
|
else
|
|
@options.docHeader = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE html>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="#{self.options.lang}">
|
|
"""
|
|
|
|
if _.isString @options.author
|
|
@options.author = [@options.author]
|
|
if _.isEmpty @options.author
|
|
@options.author = ["anonymous"]
|
|
if not @options.tempDir
|
|
@options.tempDir = path.resolve __dirname, "../tempDir/"
|
|
@id = uuid()
|
|
@uuid = path.resolve @options.tempDir, @id
|
|
@options.uuid = @uuid
|
|
@options.id = @id
|
|
@options.images = []
|
|
@options.content = _.map @options.content, (content, index)->
|
|
|
|
if !content.filename
|
|
titleSlug = uslug removeDiacritics content.title || "no title"
|
|
content.href = "#{index}_#{titleSlug}.xhtml"
|
|
content.filePath = path.resolve self.uuid, "./OEBPS/#{index}_#{titleSlug}.xhtml"
|
|
else
|
|
content.href = if content.filename.match(/\.xhtml$/) then content.filename else "#{content.filename}.xhtml"
|
|
if content.filename.match(/\.xhtml$/)
|
|
content.filePath = path.resolve self.uuid, "./OEBPS/#{content.filename}"
|
|
else
|
|
content.filePath = path.resolve self.uuid, "./OEBPS/#{content.filename}.xhtml"
|
|
|
|
content.id = "item_#{index}"
|
|
content.dir = path.dirname(content.filePath)
|
|
content.excludeFromToc ||= false
|
|
content.beforeToc ||= false
|
|
|
|
#fix Author Array
|
|
content.author =
|
|
if content.author and _.isString content.author then [content.author]
|
|
else if not content.author or not _.isArray content.author then []
|
|
else content.author
|
|
|
|
allowedAttributes = ["content", "alt" ,"id","title", "src", "href", "about", "accesskey", "aria-activedescendant", "aria-atomic", "aria-autocomplete", "aria-busy", "aria-checked", "aria-controls", "aria-describedat", "aria-describedby", "aria-disabled", "aria-dropeffect", "aria-expanded", "aria-flowto", "aria-grabbed", "aria-haspopup", "aria-hidden", "aria-invalid", "aria-label", "aria-labelledby", "aria-level", "aria-live", "aria-multiline", "aria-multiselectable", "aria-orientation", "aria-owns", "aria-posinset", "aria-pressed", "aria-readonly", "aria-relevant", "aria-required", "aria-selected", "aria-setsize", "aria-sort", "aria-valuemax", "aria-valuemin", "aria-valuenow", "aria-valuetext", "class", "content", "contenteditable", "contextmenu", "datatype", "dir", "draggable", "dropzone", "hidden", "hreflang", "id", "inlist", "itemid", "itemref", "itemscope", "itemtype", "lang", "media", "ns1:type", "ns2:alphabet", "ns2:ph", "onabort", "onblur", "oncanplay", "oncanplaythrough", "onchange", "onclick", "oncontextmenu", "ondblclick", "ondrag", "ondragend", "ondragenter", "ondragleave", "ondragover", "ondragstart", "ondrop", "ondurationchange", "onemptied", "onended", "onerror", "onfocus", "oninput", "oninvalid", "onkeydown", "onkeypress", "onkeyup", "onload", "onloadeddata", "onloadedmetadata", "onloadstart", "onmousedown", "onmousemove", "onmouseout", "onmouseover", "onmouseup", "onmousewheel", "onpause", "onplay", "onplaying", "onprogress", "onratechange", "onreadystatechange", "onreset", "onscroll", "onseeked", "onseeking", "onselect", "onshow", "onstalled", "onsubmit", "onsuspend", "ontimeupdate", "onvolumechange", "onwaiting", "prefix", "property", "rel", "resource", "rev", "role", "spellcheck", "style", "tabindex", "target", "title", "type", "typeof", "vocab", "xml:base", "xml:lang", "xml:space", "colspan", "rowspan", "epub:type", "epub:prefix"]
|
|
allowedXhtml11Tags = ["div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "address", "hr", "pre", "blockquote", "center", "ins", "del", "a", "span", "bdo", "br", "em", "strong", "dfn", "code", "samp", "kbd", "bar", "cite", "abbr", "acronym", "q", "sub", "sup", "tt", "i", "b", "big", "small", "u", "s", "strike", "basefont", "font", "object", "param", "img", "table", "caption", "colgroup", "col", "thead", "tfoot", "tbody", "tr", "th", "td", "embed", "applet", "iframe", "img", "map", "noscript", "ns:svg", "object", "script", "table", "tt", "var"]
|
|
|
|
$ = cheerio.load( content.data, {
|
|
lowerCaseTags: true,
|
|
recognizeSelfClosing: true
|
|
})
|
|
|
|
# Only body innerHTML is allowed
|
|
if $("body").length
|
|
$ = cheerio.load( $("body").html(), {
|
|
lowerCaseTags: true,
|
|
recognizeSelfClosing: true
|
|
})
|
|
$($("*").get().reverse()).each (elemIndex, elem)->
|
|
attrs = elem.attribs
|
|
that = @
|
|
if that.name in ["img", "br", "hr"]
|
|
if that.name is "img"
|
|
$(that).attr("alt", $(that).attr("alt") or "image-placeholder")
|
|
|
|
for k,v of attrs
|
|
if k in allowedAttributes
|
|
if k is "type"
|
|
if that.name isnt "script"
|
|
$(that).removeAttr(k)
|
|
else
|
|
$(that).removeAttr(k)
|
|
if self.options.version is 2
|
|
if that.name in allowedXhtml11Tags
|
|
else
|
|
console.log "Warning (content[" + index + "]):", that.name, "tag isn't allowed on EPUB 2/XHTML 1.1 DTD."
|
|
child = $(that).html()
|
|
$(that).replaceWith($("<div>" + child + "</div>"))
|
|
|
|
$("img").each (index, elem)->
|
|
url = $(elem).attr("src")
|
|
if image = self.options.images.find((element) -> element.url == url)
|
|
id = image.id
|
|
extension = image.extension
|
|
else
|
|
id = uuid()
|
|
mediaType = mime.getType url.replace /\?.*/, ""
|
|
extension = mime.getExtension mediaType
|
|
dir = content.dir
|
|
self.options.images.push {id, url, dir, mediaType, extension}
|
|
$(elem).attr("src", "images/#{id}.#{extension}")
|
|
content.data = $.xml()
|
|
content
|
|
|
|
if @options.cover
|
|
@options._coverMediaType = mime.getType @options.cover
|
|
@options._coverExtension = mime.getExtension @options._coverMediaType
|
|
|
|
@render()
|
|
@promise = @defer.promise
|
|
|
|
render: ()->
|
|
self = @
|
|
if self.options.verbose then console.log("Generating Template Files.....")
|
|
@generateTempFile().then ()->
|
|
if self.options.verbose then console.log("Downloading Images...")
|
|
self.downloadAllImage().fin ()->
|
|
if self.options.verbose then console.log("Making Cover...")
|
|
self.makeCover().then ()->
|
|
if self.options.verbose then console.log("Generating Epub Files...")
|
|
self.genEpub().then (result)->
|
|
if self.options.verbose then console.log("About to finish...")
|
|
self.defer.resolve(result)
|
|
if self.options.verbose then console.log("Done.")
|
|
, (err)->
|
|
self.defer.reject(err)
|
|
, (err)->
|
|
self.defer.reject(err)
|
|
, (err)->
|
|
self.defer.reject(err)
|
|
, (err)->
|
|
self.defer.reject(err)
|
|
|
|
generateTempFile: ()->
|
|
generateDefer = new Q.defer()
|
|
self = @
|
|
if !fs.existsSync(@options.tempDir)
|
|
fs.mkdirSync(@options.tempDir)
|
|
fs.mkdirSync @uuid
|
|
fs.mkdirSync path.resolve(@uuid, "./OEBPS")
|
|
@options.css ||= fs.readFileSync(path.resolve(__dirname, "../templates/template.css"))
|
|
fs.writeFileSync path.resolve(@uuid, "./OEBPS/style.css"), @options.css
|
|
if self.options.fonts.length
|
|
fs.mkdirSync(path.resolve @uuid, "./OEBPS/fonts")
|
|
@options.fonts = _.map @options.fonts, (font)->
|
|
if !fs.existsSync(font)
|
|
generateDefer.reject(new Error('Custom font not found at ' + font + '.'))
|
|
return generateDefer.promise
|
|
filename = path.basename(font)
|
|
fsextra.copySync(font, path.resolve(self.uuid, "./OEBPS/fonts/" + filename))
|
|
filename
|
|
_.each @options.content, (content)->
|
|
data = """#{self.options.docHeader}
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>#{entities.encodeXML(content.title || '')}</title>
|
|
<link rel="stylesheet" type="text/css" href="style.css" />
|
|
</head>
|
|
<body>
|
|
"""
|
|
data += if content.title and self.options.appendChapterTitles then "<h1>#{entities.encodeXML(content.title)}</h1>" else ""
|
|
data += if content.title and content.author and content.author.length then "<p class='epub-author'>#{entities.encodeXML(content.author.join(", "))}</p>" else ""
|
|
data += if content.title and content.url then "<p class='epub-link'><a href='#{content.url}'>#{content.url}</a></p>" else ""
|
|
data += "#{content.data}</body></html>"
|
|
fs.writeFileSync(content.filePath, data)
|
|
|
|
# write meta-inf/container.xml
|
|
fs.mkdirSync(@uuid + "/META-INF")
|
|
fs.writeFileSync( "#{@uuid}/META-INF/container.xml", """<?xml version="1.0" encoding="UTF-8" ?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>""")
|
|
|
|
if self.options.version is 2
|
|
# write meta-inf/com.apple.ibooks.display-options.xml [from pedrosanta:xhtml#6]
|
|
fs.writeFileSync "#{@uuid}/META-INF/com.apple.ibooks.display-options.xml", """
|
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<display_options>
|
|
<platform name="*">
|
|
<option name="specified-fonts">true</option>
|
|
</platform>
|
|
</display_options>
|
|
"""
|
|
|
|
opfPath = self.options.customOpfTemplatePath or path.resolve(__dirname, "../templates/epub#{self.options.version}/content.opf.ejs")
|
|
if !fs.existsSync(opfPath)
|
|
generateDefer.reject(new Error('Custom file to OPF template not found.'))
|
|
return generateDefer.promise
|
|
|
|
ncxTocPath = self.options.customNcxTocTemplatePath or path.resolve(__dirname , "../templates/toc.ncx.ejs" )
|
|
if !fs.existsSync(ncxTocPath)
|
|
generateDefer.reject(new Error('Custom file the NCX toc template not found.'))
|
|
return generateDefer.promise
|
|
|
|
htmlTocPath = self.options.customHtmlTocTemplatePath or path.resolve(__dirname, "../templates/epub#{self.options.version}/toc.xhtml.ejs")
|
|
if !fs.existsSync(htmlTocPath)
|
|
generateDefer.reject(new Error('Custom file to HTML toc template not found.'))
|
|
return generateDefer.promise
|
|
|
|
Q.all([
|
|
Q.nfcall ejs.renderFile, opfPath, self.options
|
|
Q.nfcall ejs.renderFile, ncxTocPath, self.options
|
|
Q.nfcall ejs.renderFile, htmlTocPath, self.options
|
|
]).spread (data1, data2, data3)->
|
|
fs.writeFileSync(path.resolve(self.uuid , "./OEBPS/content.opf"), data1)
|
|
fs.writeFileSync(path.resolve(self.uuid , "./OEBPS/toc.ncx"), data2)
|
|
fs.writeFileSync(path.resolve(self.uuid, "./OEBPS/toc.xhtml"), data3)
|
|
generateDefer.resolve()
|
|
, (err)->
|
|
console.error arguments
|
|
generateDefer.reject(err)
|
|
|
|
generateDefer.promise
|
|
|
|
makeCover: ()->
|
|
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.116 Safari/537.36"
|
|
coverDefer = new Q.defer()
|
|
if @options.cover
|
|
destPath = path.resolve @uuid, ("./OEBPS/cover." + @options._coverExtension)
|
|
writeStream = null
|
|
if @options.cover.slice(0,4) is "http"
|
|
writeStream = request.get(@options.cover).set 'User-Agent': userAgent
|
|
writeStream.pipe(fs.createWriteStream(destPath))
|
|
else
|
|
writeStream = fs.createReadStream(@options.cover)
|
|
writeStream.pipe(fs.createWriteStream(destPath))
|
|
|
|
writeStream.on "end", ()->
|
|
console.log "[Success] cover image downloaded successfully!"
|
|
coverDefer.resolve()
|
|
writeStream.on "error", (err)->
|
|
console.error "Error", err
|
|
coverDefer.reject(err)
|
|
else
|
|
coverDefer.resolve()
|
|
|
|
coverDefer.promise
|
|
|
|
|
|
downloadImage: (options)-> #{id, url, mediaType}
|
|
self = @
|
|
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.116 Safari/537.36"
|
|
if not options.url and typeof options isnt "string"
|
|
return false
|
|
downloadImageDefer = new Q.defer()
|
|
filename = path.resolve self.uuid, ("./OEBPS/images/" + options.id + "." + options.extension)
|
|
if options.url.indexOf("file://") == 0
|
|
auxpath = options.url.substr(7)
|
|
fsextra.copySync(auxpath, filename)
|
|
return downloadImageDefer.resolve(options)
|
|
else
|
|
if options.url.indexOf("http") is 0
|
|
requestAction = request.get(options.url).set 'User-Agent': userAgent
|
|
requestAction.pipe(fs.createWriteStream(filename))
|
|
else
|
|
requestAction = fs.createReadStream(path.resolve(options.dir, options.url))
|
|
requestAction.pipe(fs.createWriteStream(filename))
|
|
requestAction.on 'error', (err)->
|
|
console.error '[Download Error]' ,'Error while downloading', options.url, err
|
|
fs.unlinkSync(filename)
|
|
downloadImageDefer.reject(err)
|
|
|
|
requestAction.on 'end', ()->
|
|
console.log "[Download Success]", options.url
|
|
downloadImageDefer.resolve(options)
|
|
|
|
downloadImageDefer.promise
|
|
|
|
|
|
downloadAllImage: ()->
|
|
self = @
|
|
imgDefer = new Q.defer()
|
|
if not self.options.images.length
|
|
imgDefer.resolve()
|
|
else
|
|
fs.mkdirSync(path.resolve @uuid, "./OEBPS/images")
|
|
deferArray = []
|
|
_.each self.options.images, (image)->
|
|
deferArray.push self.downloadImage(image)
|
|
Q.all deferArray
|
|
.fin ()->
|
|
imgDefer.resolve()
|
|
imgDefer.promise
|
|
|
|
genEpub: ()->
|
|
# Thanks to Paul Bradley
|
|
# http://www.bradleymedia.org/gzip-markdown-epub/ (404 as of 28.07.2016)
|
|
# Web Archive URL:
|
|
# http://web.archive.org/web/20150521053611/http://www.bradleymedia.org/gzip-markdown-epub
|
|
# or Gist:
|
|
# https://gist.github.com/cyrilis/8d48eef37fbc108869ac32eb3ef97bca
|
|
|
|
genDefer = new Q.defer()
|
|
|
|
self = @
|
|
cwd = @uuid
|
|
|
|
archive = archiver("zip", {zlib: {level: 9}})
|
|
output = fs.createWriteStream self.options.output
|
|
console.log "Zipping temp dir to", self.options.output
|
|
archive.append("application/epub+zip", {store:true, name:"mimetype"})
|
|
archive.directory cwd + "/META-INF", "META-INF"
|
|
archive.directory cwd + "/OEBPS", "OEBPS"
|
|
archive.pipe output
|
|
archive.on "end", ()->
|
|
console.log "Done zipping, clearing temp dir..."
|
|
rimraf cwd, (err)->
|
|
if err
|
|
genDefer.reject(err)
|
|
else
|
|
genDefer.resolve()
|
|
archive.on "error", (err) -> genDefer.reject(err)
|
|
archive.finalize()
|
|
|
|
genDefer.promise
|
|
|
|
module.exports = EPub
|