# encoding: utf-8
# Copyright (c) 2010-2017 Kenshi Muto and Masayoshi Takahashi
#
# This program is free software.
# You can distribute or modify this program under the terms of
# the GNU LGPL, Lesser General Public License version 2.1.
# For details of the GNU LGPL, see the file "COPYING".
#
require 'tmpdir'
require 'review/i18n'
require 'review/book'
require 'review/configure'
require 'review/converter'
require 'review/latexbuilder'
require 'review/yamlloader'
require 'review/version'
require 'review/htmltoc'
require 'review/htmlbuilder'
require 'review/yamlloader'
require 'rexml/document'
require 'rexml/streamlistener'
require 'epubmaker'
module ReVIEW
class EPUBMaker
include ::EPUBMaker
include REXML
def initialize
@producer = nil
@htmltoc = nil
@buildlogtxt = "build-log.txt"
end
def log(s)
puts s if @params["debug"].present?
end
def load_yaml(yamlfile)
loader = ReVIEW::YAMLLoader.new
@params = ReVIEW::Configure.values.deep_merge(loader.load_file(yamlfile))
@producer = Producer.new(@params)
@producer.load(yamlfile)
@params = @producer.params
@params.maker = "epubmaker"
end
def build_path
if @params["debug"]
path = File.expand_path("#{@params["bookname"]}-epub", Dir.pwd)
if File.exist?(path)
FileUtils.rm_rf(path, :secure => true)
end
Dir.mkdir(path)
return path
else
return Dir.mktmpdir("#{@params["bookname"]}-epub-")
end
end
def produce(yamlfile, bookname=nil)
load_yaml(yamlfile)
I18n.setup(@params["language"])
bookname = @params["bookname"] if bookname.nil?
booktmpname = "#{bookname}-epub"
begin
@params.check_version(ReVIEW::VERSION)
rescue ReVIEW::ConfigError => e
warn e.message
end
log("Loaded yaml file (#{yamlfile}). I will produce #{bookname}.epub.")
FileUtils.rm_f("#{bookname}.epub")
FileUtils.rm_rf(booktmpname) if @params["debug"]
basetmpdir = build_path()
begin
log("Created first temporary directory as #{basetmpdir}.")
call_hook("hook_beforeprocess", basetmpdir)
@htmltoc = ReVIEW::HTMLToc.new(basetmpdir)
## copy all files into basetmpdir
copy_stylesheet(basetmpdir)
copy_frontmatter(basetmpdir)
call_hook("hook_afterfrontmatter", basetmpdir)
build_body(basetmpdir, yamlfile)
call_hook("hook_afterbody", basetmpdir)
copy_backmatter(basetmpdir)
call_hook("hook_afterbackmatter", basetmpdir)
## push contents in basetmpdir into @producer
push_contents(basetmpdir)
if @params["epubmaker"]["verify_target_images"].present?
verify_target_images(basetmpdir)
copy_images(@params["imagedir"], basetmpdir)
else
copy_images(@params["imagedir"], "#{basetmpdir}/#{@params["imagedir"]}")
end
copy_resources("covers", "#{basetmpdir}/#{@params["imagedir"]}")
copy_resources("adv", "#{basetmpdir}/#{@params["imagedir"]}")
copy_resources(@params["fontdir"], "#{basetmpdir}/fonts", @params["font_ext"])
call_hook("hook_aftercopyimage", basetmpdir)
@producer.import_imageinfo("#{basetmpdir}/#{@params["imagedir"]}", basetmpdir)
@producer.import_imageinfo("#{basetmpdir}/fonts", basetmpdir, @params["font_ext"])
epubtmpdir = nil
if @params["debug"].present?
epubtmpdir = "#{basetmpdir}/#{booktmpname}"
Dir.mkdir(epubtmpdir)
end
log("Call ePUB producer.")
@producer.produce("#{bookname}.epub", basetmpdir, epubtmpdir)
log("Finished.")
ensure
unless @params["debug"]
FileUtils.remove_entry_secure basetmpdir
end
end
end
def call_hook(hook_name, *params)
filename = @params["epubmaker"][hook_name]
log("Call #{hook_name}. (#{filename})")
if filename.present? && File.exist?(filename) && FileTest.executable?(filename)
if ENV["REVIEW_SAFE_MODE"].to_i & 1 > 0
warn "hook is prohibited in safe mode. ignored."
else
system(filename, *params)
end
end
end
def verify_target_images(basetmpdir)
@producer.contents.each do |content|
if content.media == "application/xhtml+xml"
File.open("#{basetmpdir}/#{content.file}") do |f|
Document.new(File.new(f)).each_element("//img") do |e|
@params["epubmaker"]["force_include_images"].push(e.attributes["src"])
if e.attributes["src"] =~ /svg\Z/i
content.properties.push("svg")
end
end
end
elsif content.media == "text/css"
File.open("#{basetmpdir}/#{content.file}") do |f|
f.each_line do |l|
l.scan(/url\((.+?)\)/) do |m|
@params["epubmaker"]["force_include_images"].push($1.strip)
end
end
end
end
end
@params["epubmaker"]["force_include_images"] = @params["epubmaker"]["force_include_images"].sort.uniq
end
def copy_images(resdir, destdir, allow_exts=nil)
return nil unless File.exist?(resdir)
allow_exts = @params["image_ext"] if allow_exts.nil?
FileUtils.mkdir_p(destdir)
if @params["epubmaker"]["verify_target_images"].present?
@params["epubmaker"]["force_include_images"].each do |file|
unless File.exist?(file)
warn "#{file} is not found, skip." if file !~ /\Ahttp[s]?:/
next
end
basedir = File.dirname(file)
FileUtils.mkdir_p("#{destdir}/#{basedir}")
log("Copy #{file} to the temporary directory.")
FileUtils.cp(file, "#{destdir}/#{basedir}")
end
else
recursive_copy_files(resdir, destdir, allow_exts)
end
end
def copy_resources(resdir, destdir, allow_exts=nil)
return nil unless File.exist?(resdir)
allow_exts = @params["image_ext"] if allow_exts.nil?
FileUtils.mkdir_p(destdir)
recursive_copy_files(resdir, destdir, allow_exts)
end
def recursive_copy_files(resdir, destdir, allow_exts)
Dir.open(resdir) do |dir|
dir.each do |fname|
next if fname.start_with?('.')
if FileTest.directory?("#{resdir}/#{fname}")
recursive_copy_files("#{resdir}/#{fname}", "#{destdir}/#{fname}", allow_exts)
else
if fname =~ /\.(#{allow_exts.join("|")})\Z/i
FileUtils.mkdir_p(destdir)
log("Copy #{resdir}/#{fname} to the temporary directory.")
FileUtils.cp("#{resdir}/#{fname}", destdir)
end
end
end
end
end
def check_compile_status
return unless @compile_errors
$stderr.puts "compile error, No EPUB file output."
exit 1
end
def build_body(basetmpdir, yamlfile)
@precount = 0
@bodycount = 0
@postcount = 0
@manifeststr = ""
@ncxstr = ""
@tocdesc = []
basedir = File.dirname(yamlfile)
base_path = Pathname.new(basedir)
book = ReVIEW::Book.load(basedir)
book.config = @params
@converter = ReVIEW::Converter.new(book, ReVIEW::HTMLBuilder.new)
@compile_errors = nil
book.parts.each do |part|
htmlfile = nil
if part.name.present?
if part.file?
build_chap(part, base_path, basetmpdir, true)
else
htmlfile = "part_#{part.number}.#{@params["htmlext"]}"
build_part(part, basetmpdir, htmlfile)
title = ReVIEW::I18n.t("part", part.number)
title += ReVIEW::I18n.t("chapter_postfix") + part.name.strip if part.name.strip.present?
@htmltoc.add_item(0, htmlfile, title, {:chaptype => "part"})
write_buildlogtxt(basetmpdir, htmlfile, "")
end
end
part.chapters.each do |chap|
build_chap(chap, base_path, basetmpdir, false)
end
end
check_compile_status()
end
def build_part(part, basetmpdir, htmlfile)
log("Create #{htmlfile} from a template.")
File.open("#{basetmpdir}/#{htmlfile}", "w") do |f|
@body = ""
@body << "
\n"
@body << "
#{CGI.escapeHTML(ReVIEW::I18n.t("part", part.number))}
\n"
if part.name.strip.present?
@body << "#{CGI.escapeHTML(part.name.strip)}
\n"
end
@body << "\n"
@language = @producer.params['language']
@stylesheets = @producer.params["stylesheet"]
tmplfile = File.expand_path(template_name, ReVIEW::Template::TEMPLATE_DIR)
tmpl = ReVIEW::Template.load(tmplfile)
f.write tmpl.result(binding)
end
end
def template_name
if @producer.params["htmlversion"].to_i == 5
'./html/layout-html5.html.erb'
else
'./html/layout-xhtml1.html.erb'
end
end
def build_chap(chap, base_path, basetmpdir, ispart)
filename = ""
chaptype = "body"
if ispart
chaptype = "part"
elsif chap.on_PREDEF?
chaptype = "pre"
elsif chap.on_APPENDIX?
chaptype = "post"
end
if ispart.present?
filename = chap.path
else
filename = Pathname.new(chap.path).relative_path_from(base_path).to_s
end
id = filename.sub(/\.re\Z/, "")
if @params["epubmaker"]["rename_for_legacy"] && ispart.nil?
if chap.on_PREDEF?
@precount += 1
id = sprintf("pre%02d", @precount)
elsif chap.on_APPENDIX?
@postcount += 1
id = sprintf("post%02d", @postcount)
else
@bodycount += 1
id = sprintf("chap%02d", @bodycount)
end
end
htmlfile = "#{id}.#{@params["htmlext"]}"
write_buildlogtxt(basetmpdir, htmlfile, filename)
log("Create #{htmlfile} from #{filename}.")
if @params["params"].present?
warn "'params:' in config.yml is obsoleted."
if @params["params"] =~ /stylesheet=/
warn "stylesheets should be defined in 'stylesheet:', not in 'params:'"
end
end
begin
@converter.convert(filename, File.join(basetmpdir, htmlfile))
write_info_body(basetmpdir, id, htmlfile, ispart, chaptype)
remove_hidden_title(basetmpdir, htmlfile)
rescue => e
@compile_errors = true
warn "compile error in #{filename} (#{e.class})"
warn e.message
end
end
def remove_hidden_title(basetmpdir, htmlfile)
File.open("#{basetmpdir}/#{htmlfile}", "r+") do |f|
body = f.read.
gsub(/.*?<\/h\d>\n/, '').
gsub(/(.*?<\/h\d>\n)/, '\1\2')
f.rewind
f.print body
f.truncate(f.tell)
end
end
def detect_properties(path)
properties = []
File.open(path) do |f|
doc = REXML::Document.new(f)
if REXML::XPath.first(doc, "//m:math", {'m' => 'http://www.w3.org/1998/Math/MathML'})
properties<< "mathml"
end
if REXML::XPath.first(doc, "//s:svg", {'s' => 'http://www.w3.org/2000/svg'})
properties<< "svg"
end
end
properties
end
def write_info_body(basetmpdir, id, filename, ispart=nil, chaptype=nil)
headlines = []
path = File.join(basetmpdir, filename)
Document.parse_stream(File.new(path), ReVIEWHeaderListener.new(headlines))
properties = detect_properties(path)
prop_str = ""
if properties.present?
prop_str = ",properties="+properties.join(" ")
end
first = true
headlines.each do |headline|
headline["level"] = 0 if ispart.present? && headline["level"] == 1
if first.nil?
@htmltoc.add_item(headline["level"], filename+"#"+headline["id"], headline["title"], {:chaptype => chaptype, :notoc => headline["notoc"]})
else
@htmltoc.add_item(headline["level"], filename, headline["title"], {:force_include => true, :chaptype => chaptype+prop_str, :notoc => headline["notoc"]})
first = nil
end
end
end
def push_contents(basetmpdir)
@htmltoc.each_item do |level, file, title, args|
next if level.to_i > @params["toclevel"] && args[:force_include].nil?
log("Push #{file} to ePUB contents.")
hash = {"file" => file, "level" => level.to_i, "title" => title, "chaptype" => args[:chaptype]}
if args[:id].present?
hash["id"] = args[:id]
end
if args[:properties].present?
hash["properties"] = args[:properties].split(" ")
end
if args[:notoc].present?
hash["notoc"] = args[:notoc]
end
@producer.contents.push(Content.new(hash))
end
end
def copy_stylesheet(basetmpdir)
if @params["stylesheet"].size > 0
@params["stylesheet"].each do |sfile|
FileUtils.cp(sfile, basetmpdir)
@producer.contents.push(Content.new("file" => sfile))
end
end
end
def copy_frontmatter(basetmpdir)
FileUtils.cp(@params["cover"], "#{basetmpdir}/#{File.basename(@params["cover"])}") if @params["cover"].present? && File.exist?(@params["cover"])
if @params["titlepage"]
if @params["titlefile"].nil?
build_titlepage(basetmpdir, "titlepage.#{@params["htmlext"]}")
else
FileUtils.cp(@params["titlefile"], "#{basetmpdir}/titlepage.#{@params["htmlext"]}")
end
@htmltoc.add_item(1, "titlepage.#{@params['htmlext']}", @producer.res.v("titlepagetitle"), {:chaptype => "pre"})
end
if @params["originaltitlefile"].present? && File.exist?(@params["originaltitlefile"])
FileUtils.cp(@params["originaltitlefile"], "#{basetmpdir}/#{File.basename(@params["originaltitlefile"])}")
@htmltoc.add_item(1, File.basename(@params["originaltitlefile"]), @producer.res.v("originaltitle"), {:chaptype => "pre"})
end
if @params["creditfile"].present? && File.exist?(@params["creditfile"])
FileUtils.cp(@params["creditfile"], "#{basetmpdir}/#{File.basename(@params["creditfile"])}")
@htmltoc.add_item(1, File.basename(@params["creditfile"]), @producer.res.v("credittitle"), {:chaptype => "pre"})
end
end
def build_titlepage(basetmpdir, htmlfile)
# TODO: should be created via epubcommon
@title = CGI.escapeHTML(@params.name_of("booktitle"))
File.open("#{basetmpdir}/#{htmlfile}", "w") do |f|
@body = ""
@body << "\n"
@body << "
#{CGI.escapeHTML(@params.name_of("booktitle"))}
\n"
if @params["subtitle"]
@body << "#{CGI.escapeHTML(@params.name_of("subtitle"))}
\n"
end
if @params["aut"]
@body << "#{CGI.escapeHTML(@params.names_of("aut").join(ReVIEW::I18n.t("names_splitter")))}
\n"
end
if @params["prt"]
@body << "#{CGI.escapeHTML(@params.names_of("prt").join(ReVIEW::I18n.t("names_splitter")))}
\n"
end
@body << ""
@language = @producer.params['language']
@stylesheets = @producer.params["stylesheet"]
tmplfile = File.expand_path(template_name, ReVIEW::Template::TEMPLATE_DIR)
tmpl = ReVIEW::Template.load(tmplfile)
f.write tmpl.result(binding)
end
end
def copy_backmatter(basetmpdir)
if @params["profile"]
FileUtils.cp(@params["profile"], "#{basetmpdir}/#{File.basename(@params["profile"])}")
@htmltoc.add_item(1, File.basename(@params["profile"]), @producer.res.v("profiletitle"), {:chaptype => "post"})
end
if @params["advfile"]
FileUtils.cp(@params["advfile"], "#{basetmpdir}/#{File.basename(@params["advfile"])}")
@htmltoc.add_item(1, File.basename(@params["advfile"]), @producer.res.v("advtitle"), {:chaptype => "post"})
end
if @params["colophon"]
if @params["colophon"].kind_of?(String) # FIXME: このやり方はやめる?
FileUtils.cp(@params["colophon"], "#{basetmpdir}/colophon.#{@params["htmlext"]}")
else
File.open("#{basetmpdir}/colophon.#{@params["htmlext"]}", "w") {|f| @producer.colophon(f) }
end
@htmltoc.add_item(1, "colophon.#{@params["htmlext"]}", @producer.res.v("colophontitle"), {:chaptype => "post"})
end
if @params["backcover"]
FileUtils.cp(@params["backcover"], "#{basetmpdir}/#{File.basename(@params["backcover"])}")
@htmltoc.add_item(1, File.basename(@params["backcover"]), @producer.res.v("backcovertitle"), {:chaptype => "post"})
end
end
def write_buildlogtxt(basetmpdir, htmlfile, reviewfile)
File.open("#{basetmpdir}/#{@buildlogtxt}", "a") do |f|
f.puts "#{htmlfile},#{reviewfile}"
end
end
class ReVIEWHeaderListener
include REXML::StreamListener
def initialize(headlines)
@level = nil
@content = ""
@headlines = headlines
end
def tag_start(name, attrs)
if name =~ /\Ah(\d+)/
if @level.present?
raise "#{name}, #{attrs}"
end
@level = $1.to_i
@id = attrs["id"] if attrs["id"].present?
@notoc = attrs["notoc"] if attrs["notoc"].present?
elsif !@level.nil?
if name == "img" && attrs["alt"].present?
@content << attrs["alt"]
elsif name == "a" && attrs["id"].present?
@id = attrs["id"]
end
end
end
def tag_end(name)
if name =~ /\Ah\d+/
@headlines.push({"level" => @level, "id" => @id, "title" => @content, "notoc" => @notoc}) if @id.present?
@content = ""
@level = nil
@id = nil
@notoc = nil
end
end
def text(text)
if @level.present?
@content << text.gsub("\t", " ") # FIXME: 区切り文字
end
end
end
end
end