# Copyright (c) 2010-2018 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'
require 'review/epubmaker/reviewheaderlistener'
require 'review/makerhelper'
module ReVIEW
class EPUBMaker
include ::EPUBMaker
include REXML
include MakerHelper
def initialize
@producer = nil
@htmltoc = nil
@buildlogtxt = 'build-log.txt'
@logger = ReVIEW.logger
end
def error(msg)
@logger.error "#{File.basename($PROGRAM_NAME, '.*')}: #{msg}"
exit 1
end
def warn(msg)
@logger.warn "#{File.basename($PROGRAM_NAME, '.*')}: #{msg}"
end
def log(msg)
@logger.debug(msg)
end
def load_yaml(yamlfile)
loader = ReVIEW::YAMLLoader.new
@config = ReVIEW::Configure.values
begin
@config.deep_merge!(loader.load_file(yamlfile))
rescue => e
error "yaml error #{e.message}"
end
@producer = Producer.new(@config)
@producer.load(yamlfile)
@config = @producer.config
@config.maker = 'epubmaker'
update_log_level
end
def update_log_level
if @config['debug']
@logger.level = Logger::DEBUG
else
@logger.level = Logger::INFO
end
end
def build_path
if @config['debug']
path = File.expand_path("#{@config['bookname']}-epub", Dir.pwd)
if File.exist?(path)
FileUtils.rm_rf(path, secure: true)
end
Dir.mkdir(path)
path
else
Dir.mktmpdir("#{@config['bookname']}-epub-")
end
end
def produce(yamlfile, bookname = nil)
load_yaml(yamlfile)
I18n.setup(@config['language'])
bookname ||= @config['bookname']
booktmpname = "#{bookname}-epub"
begin
@config.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")
if @config['debug']
FileUtils.rm_rf(booktmpname)
end
cleanup_mathimg
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)
math_dir = "./#{@config['imagedir']}/_review_math"
if @config['imgmath'] && File.exist?("#{math_dir}/__IMGMATH_BODY__.tex")
make_math_images(math_dir)
end
call_hook('hook_afterbackmatter', basetmpdir)
## push contents in basetmpdir into @producer
push_contents(basetmpdir)
if @config['epubmaker']['verify_target_images'].present?
verify_target_images(basetmpdir)
copy_images(@config['imagedir'], basetmpdir)
else
copy_images(@config['imagedir'], "#{basetmpdir}/#{@config['imagedir']}")
end
copy_resources('covers', "#{basetmpdir}/#{@config['imagedir']}")
copy_resources('adv', "#{basetmpdir}/#{@config['imagedir']}")
copy_resources(@config['fontdir'], "#{basetmpdir}/fonts", @config['font_ext'])
call_hook('hook_aftercopyimage', basetmpdir)
@producer.import_imageinfo("#{basetmpdir}/#{@config['imagedir']}", basetmpdir)
@producer.import_imageinfo("#{basetmpdir}/fonts", basetmpdir, @config['font_ext'])
check_image_size(basetmpdir, @config['image_maxpixels'], @config['image_ext'])
epubtmpdir = nil
if @config['debug'].present?
epubtmpdir = "#{basetmpdir}/#{booktmpname}"
Dir.mkdir(epubtmpdir)
end
log('Call ePUB producer.')
@producer.produce("#{bookname}.epub", basetmpdir, epubtmpdir)
log('Finished.')
rescue ApplicationError => e
raise if @config['debug']
error(e.message)
ensure
FileUtils.remove_entry_secure(basetmpdir) unless @config['debug']
end
end
def call_hook(hook_name, *params)
filename = @config['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|
case content.media
when 'application/xhtml+xml'
File.open("#{basetmpdir}/#{content.file}") do |f|
Document.new(File.new(f)).each_element('//img') do |e|
@config['epubmaker']['force_include_images'].push(e.attributes['src'])
if e.attributes['src'] =~ /svg\Z/i
content.properties.push('svg')
end
end
end
when 'text/css'
File.open("#{basetmpdir}/#{content.file}") do |f|
f.each_line do |l|
l.scan(/url\((.+?)\)/) do |_m|
@config['epubmaker']['force_include_images'].push($1.strip)
end
end
end
end
end
@config['epubmaker']['force_include_images'] = @config['epubmaker']['force_include_images'].compact.sort.uniq
end
def copy_images(resdir, destdir, allow_exts = nil)
return nil unless File.exist?(resdir)
allow_exts ||= @config['image_ext']
FileUtils.mkdir_p(destdir)
if @config['epubmaker']['verify_target_images'].present?
@config['epubmaker']['force_include_images'].each do |file|
unless File.exist?(file)
if file !~ /\Ahttp[s]?:/
warn "#{file} is not found, skip."
end
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 ||= @config['image_ext']
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)
elsif 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
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 = @config
@converter = ReVIEW::Converter.new(book, ReVIEW::HTMLBuilder.new)
@compile_errors = nil
book.parts.each do |part|
if part.name.present?
if part.file?
build_chap(part, base_path, basetmpdir, true)
else
htmlfile = "part_#{part.number}.#{@config['htmlext']}"
build_part(part, basetmpdir, htmlfile)
title = ReVIEW::I18n.t('part', part.number)
if part.name.strip.present?
title += ReVIEW::I18n.t('chapter_postfix') + part.name.strip
end
@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 << %Q(
\n)
@body << %Q(
#{CGI.escapeHTML(ReVIEW::I18n.t('part', part.number))}
\n)
if part.name.strip.present?
@body << %Q(#{CGI.escapeHTML(part.name.strip)}
\n)
end
@body << %Q(\n)
@language = @producer.config['language']
@stylesheets = @producer.config['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.config['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)
chaptype = 'body'
if ispart
chaptype = 'part'
elsif chap.on_predef?
chaptype = 'pre'
elsif chap.on_appendix?
chaptype = 'post'
end
filename =
if ispart.present?
chap.path
else
Pathname.new(chap.path).relative_path_from(base_path).to_s
end
id = File.basename(filename).sub(/\.re\Z/, '')
if @config['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}.#{@config['htmlext']}"
write_buildlogtxt(basetmpdir, htmlfile, filename)
log("Create #{htmlfile} from #{filename}.")
if @config['params'].present?
warn %Q('params:' in config.yml is obsoleted.)
if @config['params'] =~ /stylesheet=/
warn %Q(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(%r{.*?\n}, '').
gsub(%r{(.*?\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)
htmlio = File.new(path)
Document.parse_stream(htmlio, ReVIEWHeaderListener.new(headlines))
properties = detect_properties(path)
if properties.present?
prop_str = ',properties=' + properties.join(' ')
else
prop_str = ''
end
first = true
headlines.each do |headline|
if ispart.present? && headline['level'] == 1
headline['level'] = 0
end
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
htmlio.close
end
def push_contents(_basetmpdir)
@htmltoc.each_item do |level, file, title, args|
next if level.to_i > @config['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)
return if @config['stylesheet'].empty?
@config['stylesheet'].each do |sfile|
FileUtils.cp(sfile, basetmpdir)
@producer.contents.push(Content.new('file' => sfile))
end
end
def copy_frontmatter(basetmpdir)
if @config['cover'].present? && File.exist?(@config['cover'])
FileUtils.cp(@config['cover'],
"#{basetmpdir}/#{File.basename(@config['cover'])}")
end
if @config['titlepage']
if @config['titlefile'].nil?
build_titlepage(basetmpdir, "titlepage.#{@config['htmlext']}")
else
FileUtils.cp(@config['titlefile'],
"#{basetmpdir}/titlepage.#{@config['htmlext']}")
end
@htmltoc.add_item(1,
"titlepage.#{@config['htmlext']}",
@producer.res.v('titlepagetitle'),
chaptype: 'pre')
end
if @config['originaltitlefile'].present? && File.exist?(@config['originaltitlefile'])
FileUtils.cp(@config['originaltitlefile'],
"#{basetmpdir}/#{File.basename(@config['originaltitlefile'])}")
@htmltoc.add_item(1,
File.basename(@config['originaltitlefile']),
@producer.res.v('originaltitle'),
chaptype: 'pre')
end
if @config['creditfile'].present? && File.exist?(@config['creditfile'])
FileUtils.cp(@config['creditfile'],
"#{basetmpdir}/#{File.basename(@config['creditfile'])}")
@htmltoc.add_item(1,
File.basename(@config['creditfile']),
@producer.res.v('credittitle'),
chaptype: 'pre')
end
true
end
def build_titlepage(basetmpdir, htmlfile)
# TODO: should be created via epubcommon
@title = CGI.escapeHTML(@config.name_of('booktitle'))
File.open("#{basetmpdir}/#{htmlfile}", 'w') do |f|
@body = ''
@body << %Q(\n)
@body << %Q(
#{CGI.escapeHTML(@config.name_of('booktitle'))}
\n)
if @config['subtitle']
@body << %Q(#{CGI.escapeHTML(@config.name_of('subtitle'))}
\n)
end
if @config['aut']
@body << %Q(#{CGI.escapeHTML(@config.names_of('aut').join(ReVIEW::I18n.t('names_splitter')))}
\n)
end
if @config['pbl']
@body << %Q(#{CGI.escapeHTML(@config.names_of('pbl').join(ReVIEW::I18n.t('names_splitter')))}
\n)
end
@body << ''
@language = @producer.config['language']
@stylesheets = @producer.config['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 @config['profile']
FileUtils.cp(@config['profile'],
"#{basetmpdir}/#{File.basename(@config['profile'])}")
@htmltoc.add_item(1,
File.basename(@config['profile']),
@producer.res.v('profiletitle'),
chaptype: 'post')
end
if @config['advfile']
FileUtils.cp(@config['advfile'],
"#{basetmpdir}/#{File.basename(@config['advfile'])}")
@htmltoc.add_item(1,
File.basename(@config['advfile']),
@producer.res.v('advtitle'),
chaptype: 'post')
end
if @config['colophon']
if @config['colophon'].is_a?(String) # FIXME: should let obsolete this style?
FileUtils.cp(@config['colophon'],
"#{basetmpdir}/colophon.#{@config['htmlext']}")
else
filename = "#{basetmpdir}/colophon.#{@config['htmlext']}"
File.open(filename, 'w') do |f|
@producer.colophon(f)
end
end
@htmltoc.add_item(1,
"colophon.#{@config['htmlext']}",
@producer.res.v('colophontitle'),
chaptype: 'post')
end
if @config['backcover']
FileUtils.cp(@config['backcover'],
"#{basetmpdir}/#{File.basename(@config['backcover'])}")
@htmltoc.add_item(1,
File.basename(@config['backcover']),
@producer.res.v('backcovertitle'),
chaptype: 'post')
end
true
end
def write_buildlogtxt(basetmpdir, htmlfile, reviewfile)
File.open("#{basetmpdir}/#{@buildlogtxt}", 'a') do |f|
f.puts "#{htmlfile},#{reviewfile}"
end
end
def check_image_size(basetmpdir, maxpixels, allow_exts = nil)
begin
require 'image_size'
rescue LoadError
return nil
end
require 'find'
allow_exts ||= @config['image_ext']
pat = '\\.(' + allow_exts.delete_if { |t| %w[ttf woff otf].member?(t.downcase) }.join('|') + ')'
extre = Regexp.new(pat, Regexp::IGNORECASE)
Find.find(basetmpdir) do |fname|
next unless fname.match(extre)
img = ImageSize.path(fname)
next if img.width.nil? || img.width * img.height <= maxpixels
h = Math.sqrt(img.height * maxpixels / img.width)
w = maxpixels / h
fname.sub!("#{basetmpdir}/", '')
warn "#{fname}: #{img.width}x#{img.height} exceeds a limit. suggeted value is #{w.to_i}x#{h.to_i}"
end
true
end
end
end