epub_tools 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +3 -8
- data/.gitignore +0 -1
- data/.tool-versions +1 -1
- data/CLAUDE.md +17 -25
- data/Gemfile +5 -8
- data/Gemfile.lock +60 -54
- data/README.md +31 -4
- data/bin/epub-tools +1 -3
- data/epub_tools.gemspec +3 -4
- data/lib/epub_tools/add_chapters.rb +17 -18
- data/lib/epub_tools/append_book.rb +8 -15
- data/lib/epub_tools/book_builder.rb +28 -22
- data/lib/epub_tools/chapter_order.rb +33 -0
- data/lib/epub_tools/chapter_validator.rb +6 -3
- data/lib/epub_tools/cli.rb +121 -23
- data/lib/epub_tools/compile_book.rb +1 -1
- data/lib/epub_tools/epub_initializer.rb +93 -99
- data/lib/epub_tools/pack_ebook.rb +2 -14
- data/lib/epub_tools/pdf_converter.rb +113 -0
- data/lib/epub_tools/pdf_layout.rb +116 -0
- data/lib/epub_tools/set_cover.rb +116 -0
- data/lib/epub_tools/split_chapters.rb +22 -20
- data/lib/epub_tools/style_finder.rb +11 -40
- data/lib/epub_tools/unpack_ebook.rb +2 -20
- data/lib/epub_tools/version.rb +1 -1
- data/lib/epub_tools/xhtml_cleaner.rb +4 -7
- data/lib/epub_tools/xhtml_extractor.rb +1 -7
- data/lib/epub_tools.rb +2 -1
- data/test/add_chapters_test.rb +64 -0
- data/test/append_book_test.rb +11 -11
- data/test/chapter_validator_test.rb +12 -0
- data/test/cli_test.rb +40 -3
- data/test/compile_book_test.rb +14 -0
- data/test/epub_initializer_test.rb +18 -0
- data/test/pdf_converter_test.rb +69 -0
- data/test/pdf_layout_test.rb +57 -0
- data/test/set_cover_test.rb +68 -0
- data/test/split_chapters_test.rb +18 -12
- data/test/style_finder_test.rb +3 -9
- data/test/test_helper.rb +1 -1
- data/test/unpack_ebook_test.rb +1 -1
- data/test/xhtml_cleaner_test.rb +1 -4
- metadata +14 -35
- data/lib/epub_tools/chapter_marker_detector.rb +0 -46
- data/lib/epub_tools/cli/command_options_configurator.rb +0 -128
- data/lib/epub_tools/cli/command_registry.rb +0 -49
- data/lib/epub_tools/cli/option_builder.rb +0 -166
- data/lib/epub_tools/cli/runner.rb +0 -114
- data/lib/epub_tools/compile_workspace.rb +0 -40
- data/lib/epub_tools/epub_configuration.rb +0 -33
- data/lib/epub_tools/epub_file_writer.rb +0 -57
- data/lib/epub_tools/epub_metadata_builder.rb +0 -92
- data/lib/epub_tools/xhtml_generator.rb +0 -71
- data/test/cli/command_registry_test.rb +0 -68
- data/test/cli/option_builder_test.rb +0 -183
- data/test/cli/runner_test.rb +0 -91
- data/test/compile_workspace_test.rb +0 -55
|
@@ -4,13 +4,14 @@ require 'fileutils'
|
|
|
4
4
|
require_relative 'loggable'
|
|
5
5
|
require_relative 'xhtml_extractor'
|
|
6
6
|
require_relative 'split_chapters'
|
|
7
|
+
require_relative 'pdf_converter'
|
|
7
8
|
require_relative 'add_chapters'
|
|
8
9
|
require_relative 'pack_ebook'
|
|
9
|
-
require_relative 'compile_workspace'
|
|
10
10
|
require_relative 'chapter_validator'
|
|
11
11
|
|
|
12
12
|
module EpubTools
|
|
13
|
-
# Base class for book-building workflows (compile and append).
|
|
13
|
+
# Base class for book-building workflows (compile and append). Sources can be EPUBs (split into chapters)
|
|
14
|
+
# and/or chapter PDFs (see {PDFConverter}[rdoc-ref:EpubTools::PDFConverter] for the naming convention).
|
|
14
15
|
# Uses template method pattern — subclasses override hooks to customize behavior.
|
|
15
16
|
class BookBuilder
|
|
16
17
|
include Loggable
|
|
@@ -21,7 +22,6 @@ module EpubTools
|
|
|
21
22
|
@source_dir = options.fetch(:source_dir)
|
|
22
23
|
@build_dir = options[:build_dir] || File.join(Dir.pwd, '.epub_tools_build')
|
|
23
24
|
@verbose = options[:verbose] || false
|
|
24
|
-
@workspace = CompileWorkspace.new(@build_dir)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# Run the full build workflow
|
|
@@ -31,6 +31,7 @@ module EpubTools
|
|
|
31
31
|
prepare_epub
|
|
32
32
|
extract_xhtmls
|
|
33
33
|
split_xhtmls
|
|
34
|
+
convert_pdfs
|
|
34
35
|
validate_chapters
|
|
35
36
|
before_add_chapters
|
|
36
37
|
add_chapters
|
|
@@ -46,47 +47,48 @@ module EpubTools
|
|
|
46
47
|
# Hook: called after validation, before adding chapters
|
|
47
48
|
def before_add_chapters; end
|
|
48
49
|
|
|
49
|
-
# Subclasses
|
|
50
|
-
def book_title
|
|
51
|
-
raise NotImplementedError, "#{self.class} must implement #book_title"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Subclasses must implement: the output file path for pack_epub
|
|
55
|
-
def output_path
|
|
56
|
-
raise NotImplementedError, "#{self.class} must implement #output_path"
|
|
57
|
-
end
|
|
50
|
+
# Subclasses also define +book_title+ (used when splitting chapters) and +output_path+ (the EPUB to write)
|
|
58
51
|
|
|
59
52
|
def setup_workspace
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
FileUtils.rm_rf(build_dir)
|
|
54
|
+
FileUtils.mkdir_p([xhtml_dir, chapters_dir])
|
|
62
55
|
log 'Preparing build directories...'
|
|
63
56
|
end
|
|
64
57
|
|
|
65
58
|
def extract_xhtmls
|
|
66
59
|
log "Extracting XHTML files from EPUBs in '#{source_dir}'..."
|
|
67
|
-
XHTMLExtractor.new(source_dir: source_dir, target_dir:
|
|
60
|
+
XHTMLExtractor.new(source_dir: source_dir, target_dir: xhtml_dir, verbose: verbose).run
|
|
68
61
|
end
|
|
69
62
|
|
|
70
63
|
def split_xhtmls
|
|
71
|
-
Dir.glob(File.join(
|
|
64
|
+
Dir.glob(File.join(xhtml_dir, '*.xhtml')).each { |f| split_xhtml_file(f) }
|
|
72
65
|
end
|
|
73
66
|
|
|
74
67
|
def split_xhtml_file(xhtml_file)
|
|
75
68
|
log "Splitting '#{File.basename(xhtml_file, '.xhtml')}'..."
|
|
76
69
|
SplitChapters.new(
|
|
77
70
|
input_file: xhtml_file, book_title: book_title,
|
|
78
|
-
output_dir:
|
|
71
|
+
output_dir: chapters_dir, verbose: verbose
|
|
72
|
+
).run
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def convert_pdfs
|
|
76
|
+
return if Dir.glob(File.join(source_dir, '*.pdf')).empty?
|
|
77
|
+
|
|
78
|
+
log "Converting PDFs in '#{source_dir}'..."
|
|
79
|
+
PDFConverter.new(
|
|
80
|
+
source_dir: source_dir, book_title: book_title, output_dir: chapters_dir, verbose: verbose
|
|
79
81
|
).run
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
def validate_chapters
|
|
83
|
-
ChapterValidator.new(chapters_dir:
|
|
85
|
+
ChapterValidator.new(chapters_dir: chapters_dir, verbose: verbose).validate
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
def add_chapters
|
|
87
89
|
log 'Adding chapters to EPUB...'
|
|
88
90
|
AddChapters.new(
|
|
89
|
-
chapters_dir:
|
|
91
|
+
chapters_dir: chapters_dir,
|
|
90
92
|
oebps_dir: epub_oebps_dir,
|
|
91
93
|
verbose: verbose
|
|
92
94
|
).run
|
|
@@ -94,15 +96,19 @@ module EpubTools
|
|
|
94
96
|
|
|
95
97
|
def pack_epub
|
|
96
98
|
log "Building EPUB '#{output_path}'..."
|
|
97
|
-
|
|
99
|
+
# Expanded here: PackEbook puts a relative path next to epub_dir, inside the build dir that gets deleted
|
|
100
|
+
PackEbook.new(input_dir: epub_dir, output_file: File.expand_path(output_path), verbose: verbose).run
|
|
98
101
|
end
|
|
99
102
|
|
|
100
103
|
def finalize_and_cleanup
|
|
101
104
|
log "Done. Output EPUB: #{File.expand_path(output_path)}"
|
|
102
|
-
|
|
105
|
+
FileUtils.rm_rf(build_dir)
|
|
103
106
|
output_path
|
|
104
107
|
end
|
|
105
108
|
|
|
106
|
-
def
|
|
109
|
+
def xhtml_dir = File.join(build_dir, 'xhtml')
|
|
110
|
+
def chapters_dir = File.join(build_dir, 'chapters')
|
|
111
|
+
def epub_dir = File.join(build_dir, 'epub')
|
|
112
|
+
def epub_oebps_dir = File.join(epub_dir, 'OEBPS')
|
|
107
113
|
end
|
|
108
114
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EpubTools
|
|
4
|
+
# Reading order of chapter files: chapter_1, chapter_1_5, chapter_1a, chapter_1b, chapter_2, ..., chapter_epilogue
|
|
5
|
+
module ChapterOrder
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# @param filename [String] A chapter filename, e.g. +chapter_12a.xhtml+
|
|
9
|
+
# @return [Array] A key that sorts chapter filenames in reading order
|
|
10
|
+
def sort_key(filename)
|
|
11
|
+
basename = File.basename(filename, '.xhtml')
|
|
12
|
+
return [Float::INFINITY, ''] if basename == 'chapter_epilogue'
|
|
13
|
+
|
|
14
|
+
if (m = basename.match(/_(\d+)_5\z/))
|
|
15
|
+
[m[1].to_f + 0.5, '']
|
|
16
|
+
else
|
|
17
|
+
[basename[/\d+/].to_f, basename[/\d+([a-z]*)\z/, 1].to_s]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Inserts +node+ into +parent+ before the first chapter that sorts after +filename+, so a chapter added
|
|
22
|
+
# later (e.g. 12a after 13 is already in the book) still lands in reading order.
|
|
23
|
+
# @yield [element] Each existing child element; returns the filename it points to
|
|
24
|
+
def insert(parent, node, filename)
|
|
25
|
+
key = sort_key(filename)
|
|
26
|
+
later = parent.element_children.find do |el|
|
|
27
|
+
href = yield(el)
|
|
28
|
+
href&.start_with?('chapter_') && (sort_key(href) <=> key) == 1
|
|
29
|
+
end
|
|
30
|
+
later ? later.add_previous_sibling(node) : parent.add_child(node)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -13,13 +13,14 @@ module EpubTools
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# Validates that integer chapter numbers form a complete sequence with no gaps.
|
|
16
|
-
# Half-chapters (e.g. chapter_5_5.xhtml)
|
|
16
|
+
# Half-chapters (e.g. chapter_5_5.xhtml) and lettered side chapters (e.g. chapter_5a.xhtml) are
|
|
17
|
+
# recognized but not required.
|
|
17
18
|
# @raise [RuntimeError] if no chapter files are found or if integer chapters have gaps
|
|
18
19
|
def validate
|
|
19
20
|
log 'Validating chapter sequence...'
|
|
20
21
|
nums = extract_chapter_numbers
|
|
21
22
|
check_sequence_completeness(nums)
|
|
22
|
-
log "Chapter sequence is complete: #{nums.first} to #{nums.last}."
|
|
23
|
+
log "Chapter sequence is complete: #{nums.first.floor} to #{nums.last.floor}."
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
private
|
|
@@ -34,7 +35,7 @@ module EpubTools
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def extract_chapter_number(basename)
|
|
37
|
-
if (m = basename.match(/_(\d+)_5\z/))
|
|
38
|
+
if (m = basename.match(/_(\d+)(?:_5|[a-z]+)\z/))
|
|
38
39
|
m[1].to_i + 0.5
|
|
39
40
|
elsif (m = basename.match(/_(\d+)\z/))
|
|
40
41
|
m[1].to_i
|
|
@@ -43,6 +44,8 @@ module EpubTools
|
|
|
43
44
|
|
|
44
45
|
def check_sequence_completeness(sorted)
|
|
45
46
|
integers = sorted.select { |n| n == n.to_i }.map(&:to_i)
|
|
47
|
+
return if integers.empty? # e.g. appending only side chapters
|
|
48
|
+
|
|
46
49
|
missing = (integers.first..integers.last).to_a - integers
|
|
47
50
|
raise "Missing chapter numbers: #{missing.join(' ')}" if missing.any?
|
|
48
51
|
end
|
data/lib/epub_tools/cli.rb
CHANGED
|
@@ -1,33 +1,131 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative '
|
|
5
|
-
require_relative '
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
require_relative 'epub_initializer'
|
|
6
|
+
require_relative 'xhtml_extractor'
|
|
7
|
+
require_relative 'split_chapters'
|
|
8
|
+
require_relative 'pdf_converter'
|
|
9
|
+
require_relative 'add_chapters'
|
|
10
|
+
require_relative 'pack_ebook'
|
|
11
|
+
require_relative 'unpack_ebook'
|
|
12
|
+
require_relative 'set_cover'
|
|
13
|
+
require_relative 'compile_book'
|
|
14
|
+
require_relative 'append_book'
|
|
6
15
|
|
|
7
16
|
module EpubTools
|
|
8
|
-
#
|
|
17
|
+
# Command line interface: parses a command's flags and runs the class that implements it
|
|
9
18
|
module CLI
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
TITLE = ['-t TITLE', '--title TITLE', 'Book title', :title, :required].freeze
|
|
20
|
+
AUTHOR = ['-a AUTHOR', '--author AUTHOR', 'Author name', :author, :required].freeze
|
|
21
|
+
COVER = ['-c PATH', '--cover PATH', 'Cover image file path (optional)', :cover_image].freeze
|
|
22
|
+
BOOK_TITLE = ['-t TITLE', '--title TITLE', 'Book title for HTML <title> tags', :book_title, :required].freeze
|
|
23
|
+
CHAPTERS_OUT = ['-o DIR', '--output-dir DIR', 'Output directory for chapter files (default: ./chapters)',
|
|
24
|
+
:output_dir].freeze
|
|
25
|
+
|
|
26
|
+
# Each command's class, usage summary and flags as [short, long, description, option key, :required].
|
|
27
|
+
# Commands log progress unless run with -q, except those marked <tt>verbose: false</tt>.
|
|
28
|
+
COMMANDS = {
|
|
29
|
+
'init' => { class: EpubInitializer, summary: 'Initialize a bare-bones EPUB',
|
|
30
|
+
flags: [TITLE, AUTHOR, ['-o DIR', '--output-dir DIR', 'Destination EPUB directory',
|
|
31
|
+
:destination, :required], COVER] },
|
|
32
|
+
'extract' => { class: XHTMLExtractor, summary: 'Extract XHTML files from EPUBs',
|
|
33
|
+
flags: [['-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from',
|
|
34
|
+
:source_dir, :required],
|
|
35
|
+
['-t DIR', '--target-dir DIR',
|
|
36
|
+
'Directory where the XHTML files will be extracted to', :target_dir, :required]] },
|
|
37
|
+
'split' => { class: SplitChapters, summary: 'Split XHTML into separate XHTMLs per chapter',
|
|
38
|
+
flags: [['-i FILE', '--input FILE', 'Source XHTML file', :input_file, :required], BOOK_TITLE,
|
|
39
|
+
CHAPTERS_OUT] },
|
|
40
|
+
'pdf' => { class: PDFConverter, summary: 'Convert chapter PDFs into chapter XHTMLs',
|
|
41
|
+
flags: [['-s DIR', '--source-dir DIR', "Dir of PDFs named '12a Title.pdf'", :source_dir, :required],
|
|
42
|
+
BOOK_TITLE, CHAPTERS_OUT] },
|
|
43
|
+
'add' => { class: AddChapters, summary: 'Add chapter XHTML files into an EPUB', verbose: false,
|
|
44
|
+
flags: [['-c DIR', '--chapters-dir DIR', 'Chapters directory', :chapters_dir, :required],
|
|
45
|
+
['-e DIR', '--oebps-dir DIR', 'EPUB OEBPS directory', :oebps_dir, :required]] },
|
|
46
|
+
'pack' => { class: PackEbook, summary: 'Package an EPUB directory into a .epub file',
|
|
47
|
+
flags: [['-i DIR', '--input-dir DIR', 'EPUB directory to package', :input_dir, :required],
|
|
48
|
+
['-o FILE', '--output-file FILE', 'Output EPUB file path', :output_file, :required]] },
|
|
49
|
+
'unpack' => { class: UnpackEbook, summary: 'Unpack an EPUB file into a directory',
|
|
50
|
+
flags: [['-i FILE', '--input-file FILE', 'EPUB file to unpack', :epub_file, :required],
|
|
51
|
+
['-o DIR', '--output-dir DIR',
|
|
52
|
+
'Output directory to extract into (default: basename of epub)', :output_dir]] },
|
|
53
|
+
'cover' => { class: SetCover, summary: 'Add or replace the cover of an EPUB',
|
|
54
|
+
flags: [['-i FILE', '--input-file FILE', 'EPUB to add the cover to', :epub_file, :required],
|
|
55
|
+
['-c PATH', '--cover PATH', 'Cover image: jpg, png, gif or svg', :cover_image, :required]] },
|
|
56
|
+
'compile' => { class: CompileBook,
|
|
57
|
+
summary: 'Takes EPUBs and/or chapter PDFs in a dir and compiles them into a single EPUB.',
|
|
58
|
+
flags: [TITLE, AUTHOR,
|
|
59
|
+
['-s DIR', '--source-dir DIR', 'Directory with EPUBs and/or chapter PDFs',
|
|
60
|
+
:source_dir, :required],
|
|
61
|
+
['-o FILE', '--output FILE', 'EPUB to create (default: <Book_Title>.epub)',
|
|
62
|
+
:output_file], COVER] },
|
|
63
|
+
'append' => { class: AppendBook,
|
|
64
|
+
summary: 'Takes EPUBs and/or chapter PDFs in a dir and appends them to an existing EPUB.',
|
|
65
|
+
flags: [['-s DIR', '--source-dir DIR',
|
|
66
|
+
'Directory with EPUBs and/or chapter PDFs to append', :source_dir, :required],
|
|
67
|
+
['-t FILE', '--target-epub FILE', 'Existing EPUB file to append to',
|
|
68
|
+
:target_epub, :required]] }
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
# Runs the command named by the first argument, or prints the version or usage
|
|
74
|
+
# @param args [Array<String>] Command line arguments
|
|
75
|
+
# @return [Object] The command instance that ran
|
|
76
|
+
def run(args)
|
|
77
|
+
if ['-v', '--version'].include?(args[0])
|
|
78
|
+
puts VERSION
|
|
79
|
+
exit
|
|
80
|
+
end
|
|
81
|
+
usage unless COMMANDS.key?(args[0])
|
|
82
|
+
|
|
83
|
+
name, *rest = args
|
|
84
|
+
COMMANDS[name][:class].new(parse(name, rest)).tap(&:run)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param name [String] Command name
|
|
88
|
+
# @param args [Array<String>] The command's arguments
|
|
89
|
+
# @return [Hash] Options for the command's class; exits with the help text if they are invalid
|
|
90
|
+
def parse(name, args)
|
|
91
|
+
command = COMMANDS.fetch(name)
|
|
92
|
+
options = verbose?(command) ? { verbose: true } : {}
|
|
93
|
+
parser = parser_for(name, command, options)
|
|
94
|
+
parser.parse(args)
|
|
95
|
+
missing = missing_flags(command, options)
|
|
96
|
+
abort "Missing required options: #{missing.join(', ')}\n#{parser}" if missing.any?
|
|
97
|
+
|
|
98
|
+
options
|
|
99
|
+
rescue OptionParser::ParseError => e
|
|
100
|
+
abort "#{e.message}\n#{parser}"
|
|
17
101
|
end
|
|
18
102
|
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
registry.register('add', EpubTools::AddChapters, %i[chapters_dir oebps_dir])
|
|
23
|
-
registry.register('extract', EpubTools::XHTMLExtractor, %i[source_dir target_dir], { verbose: true })
|
|
24
|
-
registry.register('split', EpubTools::SplitChapters, %i[input_file book_title],
|
|
25
|
-
{ output_dir: './chapters', prefix: 'chapter', verbose: true })
|
|
26
|
-
registry.register('init', EpubTools::EpubInitializer, %i[title author destination], { verbose: true })
|
|
27
|
-
registry.register('pack', EpubTools::PackEbook, %i[input_dir output_file], { verbose: true })
|
|
28
|
-
registry.register('unpack', EpubTools::UnpackEbook, [:epub_file], { verbose: true })
|
|
29
|
-
registry.register('compile', EpubTools::CompileBook, %i[title author source_dir], { verbose: true })
|
|
30
|
-
registry.register('append', EpubTools::AppendBook, %i[source_dir target_epub], { verbose: true })
|
|
103
|
+
# @return [Array<String>] The long names of required flags that were not given, e.g. +--title+
|
|
104
|
+
def missing_flags(command, options)
|
|
105
|
+
command[:flags].filter_map { |_, long, _, key, required| long.split.first if required && options[key].nil? }
|
|
31
106
|
end
|
|
107
|
+
|
|
108
|
+
def parser_for(name, command, options)
|
|
109
|
+
OptionParser.new("Usage: #{program_name} #{name} [options]") do |parser|
|
|
110
|
+
command[:flags].each do |short, long, desc, key, required|
|
|
111
|
+
parser.on(short, long, required ? "#{desc} (required)" : desc) { |v| options[key] = v }
|
|
112
|
+
end
|
|
113
|
+
parser.on('-q', '--quiet', 'Run quietly (default: verbose)') { options[:verbose] = false } if verbose?(command)
|
|
114
|
+
parser.on('-h', '--help', 'Print this help') do
|
|
115
|
+
puts parser
|
|
116
|
+
exit
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def usage
|
|
122
|
+
puts "Usage: #{program_name} COMMAND [options]", 'Commands:'
|
|
123
|
+
COMMANDS.each { |name, command| puts " #{name.ljust(9)} #{command[:summary]}" }
|
|
124
|
+
exit 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def verbose?(command) = command.fetch(:verbose, true)
|
|
128
|
+
|
|
129
|
+
def program_name = File.basename($PROGRAM_NAME)
|
|
32
130
|
end
|
|
33
131
|
end
|
|
@@ -24,7 +24,7 @@ module EpubTools
|
|
|
24
24
|
|
|
25
25
|
def before_add_chapters
|
|
26
26
|
log 'Initializing new EPUB...'
|
|
27
|
-
options = { title: title, author: author, destination:
|
|
27
|
+
options = { title: title, author: author, destination: epub_dir }
|
|
28
28
|
options[:cover_image] = cover_image if cover_image
|
|
29
29
|
EpubInitializer.new(options).run
|
|
30
30
|
end
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'time'
|
|
5
6
|
require_relative 'loggable'
|
|
6
|
-
require_relative '
|
|
7
|
-
require_relative 'epub_metadata_builder'
|
|
8
|
-
require_relative 'epub_file_writer'
|
|
9
|
-
require_relative 'epub_configuration'
|
|
7
|
+
require_relative 'set_cover'
|
|
10
8
|
|
|
11
9
|
module EpubTools
|
|
12
10
|
# Sets up a basic empty EPUB directory structure with the basic files created:
|
|
@@ -15,126 +13,122 @@ module EpubTools
|
|
|
15
13
|
# - +title.xhtml+ as a title page
|
|
16
14
|
# - +package.opf+
|
|
17
15
|
# - +nav.xhtml+ as a table of contents
|
|
18
|
-
# - +style.css+
|
|
16
|
+
# - +style.css+ the basic stylesheet that ships with the gem
|
|
19
17
|
# - cover image (optionally)
|
|
20
18
|
class EpubInitializer
|
|
21
19
|
include Loggable
|
|
22
20
|
|
|
21
|
+
STYLE = File.expand_path('../../style.css', __dir__)
|
|
22
|
+
|
|
23
|
+
CONTAINER = <<~XML
|
|
24
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
26
|
+
<rootfiles>
|
|
27
|
+
<rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
|
|
28
|
+
</rootfiles>
|
|
29
|
+
</container>
|
|
30
|
+
XML
|
|
31
|
+
|
|
32
|
+
NAV = <<~XHTML
|
|
33
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
34
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<title>Table of Contents</title>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<nav epub:type="toc" id="toc">
|
|
40
|
+
<h1>Table of Contents</h1>
|
|
41
|
+
<ol>
|
|
42
|
+
<li><a href="title.xhtml">Title Page</a></li>
|
|
43
|
+
</ol>
|
|
44
|
+
</nav>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
47
|
+
XHTML
|
|
48
|
+
|
|
23
49
|
# Initializes the class
|
|
24
50
|
# @param options [Hash] Configuration options
|
|
25
51
|
# @option options [String] :title Book title (required)
|
|
26
52
|
# @option options [String] :author Book author (required)
|
|
27
53
|
# @option options [String] :destination Target directory for the EPUB files (required)
|
|
28
|
-
# @option options [String] :cover_image Optional path to the cover image
|
|
54
|
+
# @option options [String] :cover_image Optional path to the cover image, added with {SetCover}[rdoc-ref:EpubTools::SetCover]
|
|
29
55
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
30
56
|
def initialize(options = {})
|
|
31
|
-
|
|
32
|
-
@
|
|
33
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
def create_xhtml_generator
|
|
41
|
-
XhtmlGenerator.new(title: @config.title, author: @config.author)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def create_metadata_builder
|
|
45
|
-
EpubMetadataBuilder.new(@config)
|
|
57
|
+
# Escaped once here, since they are only used inside the XML templates
|
|
58
|
+
@title = options.fetch(:title).encode(xml: :text)
|
|
59
|
+
@author = options.fetch(:author).encode(xml: :text)
|
|
60
|
+
@destination = File.expand_path(options.fetch(:destination))
|
|
61
|
+
@cover_image = options[:cover_image]
|
|
62
|
+
@verbose = options[:verbose] || false
|
|
46
63
|
end
|
|
47
64
|
|
|
48
|
-
def create_file_writer
|
|
49
|
-
EpubFileWriter.new(@config.destination)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
public
|
|
53
|
-
|
|
54
65
|
# Creates the empty ebook and returns the directory
|
|
55
66
|
def run
|
|
56
|
-
@
|
|
57
|
-
@
|
|
58
|
-
|
|
59
|
-
@
|
|
60
|
-
|
|
61
|
-
write_package_opf
|
|
62
|
-
write_nav
|
|
63
|
-
@file_writer.write_style
|
|
64
|
-
log "Created empty ebook structure at: #{@config.destination}"
|
|
65
|
-
@config.destination
|
|
67
|
+
FileUtils.mkdir_p([File.join(@destination, 'META-INF'), oebps_dir])
|
|
68
|
+
files.each { |path, content| File.write(File.join(@destination, path), content) }
|
|
69
|
+
write_cover if @cover_image
|
|
70
|
+
log "Created empty ebook structure at: #{@destination}"
|
|
71
|
+
@destination
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
private
|
|
69
75
|
|
|
70
|
-
def
|
|
71
|
-
content = @xhtml_generator.build_title_page
|
|
72
|
-
@file_writer.write_xhtml('title.xhtml', content)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Copies the cover image into the EPUB structure and creates a cover.xhtml page
|
|
76
|
-
def write_cover
|
|
77
|
-
return unless cover_image_exists?
|
|
76
|
+
def oebps_dir = File.join(@destination, 'OEBPS')
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
fname = "cover#{ext}"
|
|
84
|
-
@config.update_cover_info(fname, media_type)
|
|
85
|
-
@xhtml_generator.cover_image_fname = fname
|
|
86
|
-
update_metadata_builder_with_cover_info
|
|
87
|
-
|
|
88
|
-
copy_cover_image(ext)
|
|
89
|
-
write_cover_page
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def cover_image_exists?
|
|
93
|
-
return true if File.exist?(@config.cover_image_path)
|
|
94
|
-
|
|
95
|
-
warn "Warning: cover image '#{@config.cover_image_path}' not found; skipping cover support."
|
|
96
|
-
false
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def determine_media_type(ext)
|
|
100
|
-
case ext
|
|
101
|
-
when '.jpg', '.jpeg' then 'image/jpeg'
|
|
102
|
-
when '.png' then 'image/png'
|
|
103
|
-
when '.gif' then 'image/gif'
|
|
104
|
-
when '.svg' then 'image/svg+xml'
|
|
105
|
-
else
|
|
106
|
-
warn "Warning: unsupported cover image type '#{ext}'; skipping cover support."
|
|
107
|
-
nil
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def update_metadata_builder_with_cover_info
|
|
112
|
-
@metadata_builder = EpubMetadataBuilder.new(@config)
|
|
78
|
+
def files
|
|
79
|
+
{ 'mimetype' => 'application/epub+zip', 'META-INF/container.xml' => CONTAINER,
|
|
80
|
+
'OEBPS/title.xhtml' => title_page, 'OEBPS/package.opf' => package_opf, 'OEBPS/nav.xhtml' => NAV,
|
|
81
|
+
'OEBPS/style.css' => File.read(STYLE) }
|
|
113
82
|
end
|
|
114
83
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# Generates a cover.xhtml file displaying the cover image
|
|
121
|
-
def write_cover_page
|
|
122
|
-
content = @xhtml_generator.build_cover_page
|
|
123
|
-
@file_writer.write_xhtml('cover.xhtml', content)
|
|
84
|
+
def write_cover
|
|
85
|
+
SetCover.new(cover_image: @cover_image).apply_to(oebps_dir)
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
warn "Warning: #{e.message}; skipping cover support."
|
|
124
88
|
end
|
|
125
89
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
90
|
+
def title_page
|
|
91
|
+
<<~XHTML
|
|
92
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
93
|
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="UTF-8" />
|
|
96
|
+
<title>#{@title}</title>
|
|
97
|
+
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
<h1 class="title">#{@title}</h1>
|
|
101
|
+
<p class="author">by #{@author}</p>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|
|
104
|
+
XHTML
|
|
132
105
|
end
|
|
133
106
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
107
|
+
def package_opf
|
|
108
|
+
<<~XML
|
|
109
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
110
|
+
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="en">
|
|
111
|
+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
112
|
+
<dc:identifier id="pub-id">urn:uuid:#{SecureRandom.uuid}</dc:identifier>
|
|
113
|
+
<dc:title>#{@title}</dc:title>
|
|
114
|
+
<dc:creator>#{@author}</dc:creator>
|
|
115
|
+
<dc:language>en</dc:language>
|
|
116
|
+
<meta property="dcterms:modified">#{Time.now.utc.iso8601}</meta>
|
|
117
|
+
<meta property="schema:accessMode">textual</meta>
|
|
118
|
+
<meta property="schema:accessibilityFeature">unknown</meta>
|
|
119
|
+
<meta property="schema:accessibilityHazard">none</meta>
|
|
120
|
+
<meta property="schema:accessModeSufficient">textual</meta>
|
|
121
|
+
</metadata>
|
|
122
|
+
<manifest>
|
|
123
|
+
<item id="style" href="style.css" media-type="text/css"/>
|
|
124
|
+
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
|
125
|
+
<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>
|
|
126
|
+
</manifest>
|
|
127
|
+
<spine>
|
|
128
|
+
<itemref idref="title"/>
|
|
129
|
+
</spine>
|
|
130
|
+
</package>
|
|
131
|
+
XML
|
|
138
132
|
end
|
|
139
133
|
end
|
|
140
134
|
end
|
|
@@ -17,13 +17,7 @@ module EpubTools
|
|
|
17
17
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
18
18
|
def initialize(options = {})
|
|
19
19
|
@input_dir = File.expand_path(options.fetch(:input_dir))
|
|
20
|
-
|
|
21
|
-
output_file = options[:output_file]
|
|
22
|
-
@output_file = if output_file.nil? || output_file.empty?
|
|
23
|
-
default_name
|
|
24
|
-
else
|
|
25
|
-
output_file
|
|
26
|
-
end
|
|
20
|
+
@output_file = options[:output_file].to_s.empty? ? "#{File.basename(@input_dir)}.epub" : options[:output_file]
|
|
27
21
|
@verbose = options[:verbose] || false
|
|
28
22
|
end
|
|
29
23
|
|
|
@@ -48,8 +42,7 @@ module EpubTools
|
|
|
48
42
|
|
|
49
43
|
def create_zip_file(target)
|
|
50
44
|
Zip::File.open(target, create: true) do |zip|
|
|
51
|
-
#
|
|
52
|
-
add_mimetype(zip)
|
|
45
|
+
zip.add_stored('mimetype', 'mimetype') # EPUB readers need it first and uncompressed
|
|
53
46
|
add_content_files(zip)
|
|
54
47
|
end
|
|
55
48
|
end
|
|
@@ -72,10 +65,5 @@ module EpubTools
|
|
|
72
65
|
|
|
73
66
|
raise ArgumentError, "Error: 'mimetype' file missing in #{@input_dir}"
|
|
74
67
|
end
|
|
75
|
-
|
|
76
|
-
def add_mimetype(zip)
|
|
77
|
-
# Add mimetype first and uncompressed (Stored)
|
|
78
|
-
zip.add_stored('mimetype', 'mimetype')
|
|
79
|
-
end
|
|
80
68
|
end
|
|
81
69
|
end
|