# -*- coding: utf-8 -*- # # Copyright 2013 whiteleaf. All rights reserved. # require "fileutils" require "stringio" require_relative "novelsetting" require_relative "inspector" require_relative "illustration" require_relative "loadconverter" require_relative "downloader" require_relative "template" require_relative "progressbar" require_relative "helper" require_relative "inventory" require_relative "html" require_relative "eventable" class NovelConverter include Narou::Eventable NOVEL_TEXT_TEMPLATE_NAME = "novel.txt" NOVEL_TEXT_TEMPLATE_NAME_FOR_IBUNKO = "ibunko_novel.txt" attr_reader :use_dakuten_font if Narou.already_init? @@site_settings = Downloader.load_settings end # # 指定の小説を整形・変換する # def self.convert(target, options = {}) options = { # default paraeters output_filename: nil, display_inspector: false, ignore_force: false, ignore_default: false }.merge(options) setting = NovelSetting.load(target, options[:ignore_force], options[:ignore_default]) if setting novel_converter = new(setting, options[:output_filename], options[:display_inspector]) return { converted_txt_path: novel_converter.convert_main, use_dakuten_font: novel_converter.use_dakuten_font } end nil end # # テキストファイルを整形・変換する # def self.convert_file(filename, options = {}) options = { # default parameters encoding: nil, output_filename: nil, display_inspector: false, ignore_force: false, ignore_default: false }.merge(options) output_filename = options[:output_filename] if output_filename archive_path = File.dirname(output_filename) + "/" else archive_path = File.dirname(filename) + "/" end setting = NovelSetting.create(archive_path, options[:ignore_force], options[:ignore_default]) setting.author = "" setting.title = File.basename(filename) novel_converter = new(setting, output_filename, options[:display_inspector]) text = open(filename, "r:BOM|UTF-8") { |fp| fp.read }.gsub("\r", "") if options[:encoding] text.force_encoding(options[:encoding]).encode!(Encoding::UTF_8) end { converted_txt_path: novel_converter.convert_main(text), use_dakuten_font: novel_converter.use_dakuten_font } end def self.stash_aozora_fonts_directory fonts_path = File.join(File.dirname(Narou.get_aozoraepub3_path), "template/OPS/fonts") return unless File.exist?(fonts_path) FileUtils.mv(fonts_path, fonts_path + "_hide") end def self.visible_aozora_fonts_directory fonts_path = File.join(File.dirname(Narou.get_aozoraepub3_path), "template/OPS/fonts") return unless File.exist?(fonts_path + "_hide") FileUtils.mv(fonts_path + "_hide", fonts_path) end # # AozoraEpub3でEPUBファイル作成 # # AozoraEpub3は.jarがあるところがカレントディレクトリじゃないとうまく動かない # MEMO: # 逆にカレントディレクトリにAozoraEpub3の必須ファイルを置いて手を加えることで、 # テンプレート等の差し替えが容易になる # # 返り値:正常終了 :success、エラー終了 :error、AozoraEpub3が見つからなかった nil # def self.txt_to_epub(filename, use_dakuten_font = false, dst_dir = nil, device = nil, verbose = false) abs_srcpath = File.expand_path(filename) src_dir = File.dirname(abs_srcpath) cover_option = "" # MEMO: 外部実行からだと -c FILENAME, -c 1 オプションはぬるぽが出て動かない cover_filename = get_cover_filename(src_dir) if cover_filename cover_option = "-c 0" # 先頭の挿絵を表紙として利用 end dst_option = "" if dst_dir dst_option = %!-dst "#{File.expand_path(dst_dir)}"! end ext_option = "" device_option = "" if device case device.name when "Kobo" ext_option = "-ext " + device.ebook_file_ext when "Kindle" device_option = "-device kindle" end end pwd = Dir.pwd aozoraepub3_path = Narou.get_aozoraepub3_path unless aozoraepub3_path error "AozoraEpub3が見つからなかったのでEPUBが出力出来ませんでした。" + "narou initでAozoraEpub3の設定を行なって下さい" return nil end aozoraepub3_basename = File.basename(aozoraepub3_path) aozoraepub3_dir = File.dirname(aozoraepub3_path) java_encoding = "-Dfile.encoding=UTF-8" if Helper.os_cygwin? abs_srcpath = Helper.convert_to_windows_path(abs_srcpath) end Dir.chdir(aozoraepub3_dir) command = %!java #{java_encoding} -cp #{aozoraepub3_basename} AozoraEpub3 -enc UTF-8 -of #{device_option} ! + %!#{cover_option} #{dst_option} #{ext_option} "#{abs_srcpath}"! if Helper.os_windows? command = "cmd /c " + command.encode(Encoding::Windows_31J) end stash_aozora_fonts_directory unless use_dakuten_font print "AozoraEpub3でEPUBに変換しています" begin res = Helper::AsyncCommand.exec(command) do print "." end ensure visible_aozora_fonts_directory unless use_dakuten_font Dir.chdir(pwd) end # AozoraEpub3はエラーだとしてもexitコードは0なので、 # 失敗した場合はjavaが実行できない場合と確定できる unless res[2].success? puts puts res error "JavaがインストールされていないかAozoraEpub3実行時にエラーが発生しました。EPUBを作成出来ませんでした" return :error end stdout_capture = res[0] # Javaの実行環境に由来するであろうエラー if stdout_capture =~ /Error occurred during initialization of VM/ puts warn stdout_capture.strip warn "-" * 70 error "Javaの実行エラーが発生しました。EPUBを作成出来ませんでした\n" \ "Hint: 複数のJava環境が混じっていると起きやすいエラーのようです" return :error end error_list = stdout_capture.scan(/^(?:\[ERROR\]|エラーが発生しました :).+$/) warn_list = stdout_capture.scan(/^\[WARN\].+$/) info_list = stdout_capture.scan(/^\[INFO\].+$/) if verbose puts puts "==== AozoraEpub3 stdout capture " + "=" * 47 puts stdout_capture.strip puts "=" * 79 end if !error_list.empty? || !warn_list.empty? unless verbose puts puts error_list, warn_list end unless error_list.empty? # AozoraEpub3 のエラーにはEPUBが出力されないエラーとEPUBが出力されるエラーの2種類ある。 # EPUBが出力される場合は「変換完了」という文字があるのでそれを検出する if stdout_capture !~ /^変換完了/ error "AozoraEpub3実行中にエラーが発生したため、EPUBが出力出来ませんでした" return :error end end end puts "変換しました" :success end # # EPUBファイルをkindlegenでMOBIへ # AozoraEpub3.jar と同じ場所に kindlegen が無ければ何もしない # # 返り値:正常終了 :success、エラー終了 :error、中断終了 :abort # def self.epub_to_mobi(epub_path, verbose = false) kindlegen_path = File.join(File.dirname(Narou.get_aozoraepub3_path), "kindlegen") if Dir.glob(kindlegen_path + "*").empty? error "kindlegenが見つかりませんでした。AozoraEpub3と同じディレクトリにインストールして下さい" return :error end if Helper.os_cygwin? epub_path = Helper.convert_to_windows_path(epub_path) end command = %!"#{kindlegen_path}" -locale ja "#{epub_path}"! if Helper.os_windows? command.encode!(Encoding::Windows_31J) end print "kindlegen実行中" res = Helper::AsyncCommand.exec(command) do print "." end stdout_capture, _, proccess_status = res stdout_capture.force_encoding(Encoding::UTF_8) if verbose puts puts "==== kindlegen stdout capture " + "=" * 49 puts stdout_capture.gsub("\n\n", "\n").strip puts "=" * 79 end if proccess_status.exited? if proccess_status.exitstatus == 2 puts "" error "kindlegen実行中にエラーが発生したため、MOBIが出力出来ませんでした" if stdout_capture.scan(/(エラー\(.+?\):\w+?:.+)$/) error $1 end return :error end else puts "" error "kindlegenが中断させられたぽいのでMOBIは出力出来ませんでした" return :abort end puts "変換しました" :success end # # 変換された整形済みテキストファイルをデバイスに対応した書籍データに変換する # def self.convert_txt_to_ebook_file(txt_path, options) options = { use_dakuten_font: false, dst_dir: nil, device: nil, verbose: false, no_epub: false, no_mobi: false, no_strip: false, no_cleanup_txt: false, }.merge(options) device = options[:device] clean_up_file_list = [] return false if options[:no_epub] clean_up_file_list << txt_path unless options[:no_cleanup_txt] # epub status = NovelConverter.txt_to_epub(txt_path, options[:use_dakuten_font], options[:dst_dir], device, options[:verbose]) return nil if status != :success if device && device.kobo? epub_ext = device.ebook_file_ext else epub_ext = ".epub" end epub_path = txt_path.sub(/.txt$/, epub_ext) if !device || !device.kindle? || options[:no_mobi] puts File.basename(epub_path) + " を出力しました" puts "EPUBファイルを出力しました".termcolor return epub_path end clean_up_file_list << epub_path # mobi status = NovelConverter.epub_to_mobi(epub_path, options[:verbose]) return nil if status != :success mobi_path = epub_path.sub(/\.epub$/, device.ebook_file_ext) # strip unless options[:no_strip] puts "kindlestrip実行中" begin SectionStripper.strip(mobi_path, nil, false) rescue StripException => e error "#{e.message}" end end puts File.basename(mobi_path).encode(Encoding::UTF_8) + " を出力しました" puts "MOBIファイルを出力しました".termcolor return mobi_path ensure if Narou.economy?("cleanup_temp") # 作業用ファイルを削除 clean_up_temp_files(clean_up_file_list) end end def self.clean_up_temp_files(path_list) path_list.each do |path| FileUtils.rm_f(path) end end def initialize(setting, output_filename = nil, display_inspector = false, output_text_dir = nil) @setting = setting @novel_id = setting.id @novel_author = setting.author @novel_title = setting.title @output_filename = output_filename @inspector = Inspector.new(@setting) @illustration = Illustration.new(@setting, @inspector) @display_inspector = display_inspector @use_dakuten_font = false @converter = create_converter @converter.output_text_dir = output_text_dir @data = @novel_id ? Database.instance.get_data("id", @novel_id) : {} end # # 変換処理メインループ # def convert_main(text = nil) display_header initialize_event if text converted_text = convert_main_for_text(text) else converted_text = convert_main_for_novel update_latest_convert_novel end inspect_novel(converted_text) output_path = create_output_path(text, converted_text) File.write(output_path, converted_text) display_footer output_path end def initialize_event progressbar = nil one(:"convert_main.init") do |subtitles| progressbar = ProgressBar.new(subtitles.size) end on(:"convert_main.loop") do |i| progressbar.output(i) if progressbar end one(:"convert_main.finish") do progressbar.clear if progressbar end end def display_header print "ID:#{@novel_id} " if @novel_id puts "#{@novel_title} の変換を開始" end def display_footer puts "縦書用の変換が終了しました" end def load_novel_section(subtitle_info, section_save_dir) file_subtitle = subtitle_info["file_subtitle"] || subtitle_info["subtitle"] # 互換性維持のため path = File.join(section_save_dir, "#{subtitle_info["index"]} #{file_subtitle}.yaml") YAML.load_file(path) rescue Errno::ENOENT => e error "#{path} を見つけることが出来ませんでした。narou update #{@novel_id} を実行することで、" \ "削除されてしまったファイルを再ダウンロードすることが出来ます" exit Narou::EXIT_ERROR_CODE end # is_hotentry を有効にすると、テンプレートで作成するテキストファイルに # あらすじ、作品タイトル、本の読み終わり表示が付与されなくなる def create_novel_text_by_template(sections, toc, is_hotentry = false) cover_chuki = create_cover_chuki device = Narou.get_device setting = @setting processed_title = decorate_title(toc["title"]) tempalte_name = (device && device.ibunko? ? NOVEL_TEXT_TEMPLATE_NAME_FOR_IBUNKO : NOVEL_TEXT_TEMPLATE_NAME) Template.get(tempalte_name, binding, 1.1) end # # 2045年くらいまでの残り時間を10分単位の36進数で取得する # hyff のような文字列が取得可能 # def calc_reverse_short_time(time) ((2396736000 - time.to_i) / (10 * 60)).to_s(36).rjust(4, "0") end # # タイトルに日付を付与する。 # 日付の種類は title_date_target で指定する # # strftime の書式の他に拡張書式として $s, $t をサポートする # $s 2045年くらいまでの残り時間を10分単位の36進数(4桁) # $t タイトル自身。書式の中で自由な位置にタイトルを埋め込める # $ns 小説が掲載されているサイト名 # $nt 小説種別(短編 or 連載) # # ※ $t を使用した場合、title_date_align を無視する # def add_date_to_title(title) result = title if @setting.enable_add_date_to_title new_arrivals_date = @data[@setting.title_date_target] || Time.now special_format_chars = [ ["$s", calc_reverse_short_time(new_arrivals_date)], ["$ns", @data["sitename"]], ["$nt", Narou.novel_type_text(@data["novel_type"])], ["$t", title] ] date_str = new_arrivals_date.strftime(@setting.title_date_format) doller_t_included = date_str.include?("$t") special_format_chars.each do |(symbol, replace_text)| date_str.gsub!(symbol, replace_text) end if doller_t_included # $t で任意の位置にタイトルを埋め込むために title_date_align は無視する result = date_str else if @setting.title_date_align == "left" result = date_str + result else # right result = title + date_str end end end result end def decorate_title(title) processed_title = add_date_to_title(title) # タイトルに完結したかどうかを付加する if @setting.enable_add_end_to_title tags = @data["tags"] || [] if tags.include?("end") processed_title += " (完結)" end end # タイトルがルビ化されてしまうのを抑制 processed_title = processed_title.gsub("《", "※[#始め二重山括弧]") .gsub("》", "※[#終わり二重山括弧]") processed_title end # # 表紙用の画像名取得 # def self.get_cover_filename(archive_path) [".jpg", ".png", ".jpeg"].each do |ext| filename = "cover#{ext}" cover_path = File.join(archive_path, filename) if File.exist?(cover_path) return filename end end nil end # # 表紙用挿絵注記作成 # def create_cover_chuki cover_filename = self.class.get_cover_filename(@setting.archive_path) if cover_filename "[#挿絵(#{cover_filename})入る]" else "" end end # # 目次情報からサイト設定を取得 # def find_site_setting(toc_url) @@site_settings.find { |s| s.multi_match(toc_url, "url") } end # # 各小説用の converter.rb 変換オブジェクトを生成 # def create_converter load_converter(@novel_title, @setting.archive_path).new(@setting, @inspector, @illustration) end # # 最終的に出力するパスを生成 # def create_output_path(is_text_file_mode, converted_text) output_path = "" if @output_filename output_path = File.join(@setting.archive_path, File.basename(@output_filename)) else if is_text_file_mode info = get_title_and_author_by_text(converted_text) info["ncode"] = info["title"] info["domain"] = "text" else info = { "id" => @novel_id, "author" => @novel_author, "title" => @novel_title } end filename = Narou.create_novel_filename(info) output_path = File.join(@setting.archive_path, filename) if output_path !~ /\.\w+$/ output_path += ".txt" end end output_path end # # テキストファイル変換時の実質的なメイン処理 # def convert_main_for_text(text) result = @converter.convert(text, "textfile") unless @setting.enable_enchant_midashi @inspector.info "テキストファイルの処理を実行しましたが、改行直後の見出し付与は有効になっていません。" + "setting.ini の enable_enchant_midashi を true にすることをお薦めします。" end splited = result.split("\n", 3) # 表紙の挿絵注記を3行目に挟み込む converted_text = [splited[0], splited[1], create_cover_chuki, splited[2]].join("\n") @use_dakuten_font = @converter.use_dakuten_font converted_text end # # 管理小説変換時の実質的なメイン処理 # # 引数 subtitles にデータを渡した場合はそれを直接使う # is_hotentry を有効にすると出力されるテキストファイルにあらすじや作品タイトル等が含まれなくなる # def convert_main_for_novel(subtitles = nil, is_hotentry = false) toc = Downloader.get_toc_data(@setting.archive_path) unless subtitles subtitles = cut_subtitles(toc["subtitles"]) end @converter.subtitles = subtitles toc["story"] = @converter.convert(toc["story"], "story") html = HTML.new html.strip_decoration_tag = @setting.enable_strip_decoration_tag site_setting = find_site_setting(toc["toc_url"]) html.set_illust_setting({current_url: site_setting["illust_current_url"], grep_pattern: site_setting["illust_grep_pattern"]}) sections = subtitles_to_sections(subtitles, html) converted_text = create_novel_text_by_template(sections, toc, is_hotentry) converted_text end def cut_subtitles(subtitles) case cut_size = @setting.cut_old_subtitles when 0 result = subtitles when 1...subtitles.size puts "#{cut_size}話分カットして変換します" result = subtitles[cut_size..-1] else puts "最新話のみ変換します" result = [subtitles[-1]] end result end # # subtitle info から変換処理をする # def subtitles_to_sections(subtitles, html) sections = [] section_save_dir = Downloader.get_novel_section_save_dir(@setting.archive_path) trigger(:"convert_main.init", subtitles) subtitles.each_with_index do |subinfo, i| trigger(:"convert_main.loop", i) @converter.current_index = i section = load_novel_section(subinfo, section_save_dir) if section["chapter"].length > 0 section["chapter"] = @converter.convert(section["chapter"], "chapter") end @inspector.subtitle = section["subtitle"] element = section["element"] data_type = element.delete("data_type") || "text" element.each do |text_type, elm_text| if data_type == "html" html.string = elm_text elm_text = html.to_aozora end element[text_type] = @converter.convert(elm_text, text_type) end section["subtitle"] = @converter.convert(section["subtitle"], "subtitle") sections << section end @use_dakuten_font = @converter.use_dakuten_font sections ensure trigger(:"convert_main.finish") end # # テキストデータ先頭二行からタイトルと作者名を取得 # def get_title_and_author_by_text(text) title, author = text.split("\n", 3) { "title" => title, "author" => author } end def inspect_novel(text) if @setting.enable_inspect @inspector.inspect_end_touten_conditions(text) # 行末読点の現在状況を調査する @inspector.countup_return_in_brackets(text) # カギ括弧内の改行状況を調査する end if !@display_inspector unless @inspector.empty? puts "小説状態の調査結果を #{Inspector::INSPECT_LOG_NAME} に出力しました" end else # 小説の監視・検査状況を表示する if @inspector.error? || @inspector.warning? puts "―――― 小説にエラーもしくは警告が存在します ――――".termcolor puts "" @inspector.display(Inspector::ERROR | Inspector::WARNING) puts "" end if @inspector.info? puts "―――― 小説の検査状況を表示します ――――".termcolor puts "" @inspector.display(Inspector::INFO) puts "" end end @inspector.save end # # 最近変換した小説IDを記録更新 # def update_latest_convert_novel id = Downloader.get_id_by_target(@novel_title) Inventory.load("latest_convert").tap { |inv| inv["id"] = id inv.save } end end