epub_tools 0.3.0 → 0.4.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/.document +2 -0
- data/.github/workflows/ci.yml +9 -8
- data/.gitignore +4 -0
- data/.rubocop.yml +41 -0
- data/Gemfile +17 -8
- data/Gemfile.lock +51 -0
- data/LICENSE +21 -0
- data/README.md +21 -3
- data/bin/epub-tools +3 -109
- data/epub_tools.gemspec +6 -8
- data/lib/epub_tools/add_chapters.rb +124 -0
- data/lib/epub_tools/cli/command_registry.rb +47 -0
- data/lib/epub_tools/cli/option_builder.rb +164 -0
- data/lib/epub_tools/cli/runner.rb +164 -0
- data/lib/epub_tools/cli.rb +45 -0
- data/lib/epub_tools/compile_book.rb +77 -34
- data/lib/epub_tools/epub_initializer.rb +48 -26
- data/lib/epub_tools/loggable.rb +11 -0
- data/lib/epub_tools/pack_ebook.rb +20 -13
- data/lib/epub_tools/split_chapters.rb +40 -21
- data/lib/epub_tools/style_finder.rb +58 -0
- data/lib/epub_tools/unpack_ebook.rb +23 -16
- data/lib/epub_tools/version.rb +2 -1
- data/lib/epub_tools/xhtml_cleaner.rb +28 -8
- data/lib/epub_tools/xhtml_extractor.rb +23 -10
- data/lib/epub_tools.rb +4 -2
- data/test/{add_chapters_to_epub_test.rb → add_chapters_test.rb} +14 -7
- data/test/cli/command_registry_test.rb +66 -0
- data/test/cli/option_builder_test.rb +173 -0
- data/test/cli/runner_test.rb +91 -0
- data/test/cli_commands_test.rb +100 -0
- data/test/cli_test.rb +4 -0
- data/test/cli_version_test.rb +5 -3
- data/test/compile_book_test.rb +11 -2
- data/test/epub_initializer_test.rb +51 -31
- data/test/pack_ebook_test.rb +14 -8
- data/test/split_chapters_test.rb +22 -1
- data/test/{text_style_class_finder_test.rb → style_finder_test.rb} +7 -6
- data/test/test_helper.rb +4 -5
- data/test/unpack_ebook_test.rb +21 -5
- data/test/xhtml_cleaner_test.rb +13 -7
- data/test/xhtml_extractor_test.rb +17 -1
- metadata +24 -39
- data/lib/epub_tools/add_chapters_to_epub.rb +0 -87
- data/lib/epub_tools/cli_helper.rb +0 -31
- data/lib/epub_tools/text_style_class_finder.rb +0 -47
@@ -0,0 +1,164 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module EpubTools
|
4
|
+
module CLI
|
5
|
+
# Builds and manages command line options
|
6
|
+
class OptionBuilder
|
7
|
+
attr_reader :options, :required_keys, :parser
|
8
|
+
|
9
|
+
# Initialize a new OptionBuilder
|
10
|
+
# @param default_options [Hash] Default options to start with
|
11
|
+
# @param required_keys [Array<Symbol>] Keys that must be present in the final options
|
12
|
+
def initialize(default_options = {}, required_keys = [])
|
13
|
+
@options = default_options.dup
|
14
|
+
@required_keys = required_keys
|
15
|
+
@parser = OptionParser.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# Add banner to the option parser
|
19
|
+
# @param text [String] Banner text
|
20
|
+
# @return [self] for method chaining
|
21
|
+
def with_banner(text)
|
22
|
+
@parser.banner = text
|
23
|
+
self
|
24
|
+
end
|
25
|
+
|
26
|
+
# Add help option to the parser
|
27
|
+
# @return [self] for method chaining
|
28
|
+
def with_help_option
|
29
|
+
@parser.on('-h', '--help', 'Print this help') do
|
30
|
+
puts @parser
|
31
|
+
exit
|
32
|
+
end
|
33
|
+
self
|
34
|
+
end
|
35
|
+
|
36
|
+
# Add verbose option to the parser
|
37
|
+
# @return [self] for method chaining
|
38
|
+
def with_verbose_option
|
39
|
+
@options[:verbose] = true unless @options.key?(:verbose)
|
40
|
+
@parser.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| @options[:verbose] = !v }
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
# Add input file option to the parser
|
45
|
+
# @param description [String] Option description
|
46
|
+
# @param required [Boolean] Whether this option is required
|
47
|
+
# @return [self] for method chaining
|
48
|
+
def with_input_file(description = 'Input file', required = true)
|
49
|
+
desc = required ? "#{description} (required)" : description
|
50
|
+
@parser.on('-i FILE', '--input-file FILE', desc) { |v| @options[:input_file] = v }
|
51
|
+
self
|
52
|
+
end
|
53
|
+
|
54
|
+
# Add input directory option to the parser
|
55
|
+
# @param description [String] Option description
|
56
|
+
# @param required [Boolean] Whether this option is required
|
57
|
+
# @return [self] for method chaining
|
58
|
+
def with_input_dir(description = 'Input directory', required = true)
|
59
|
+
desc = required ? "#{description} (required)" : description
|
60
|
+
@parser.on('-i DIR', '--input-dir DIR', desc) { |v| @options[:input_dir] = v }
|
61
|
+
self
|
62
|
+
end
|
63
|
+
|
64
|
+
# Add output directory option to the parser
|
65
|
+
# @param description [String] Option description
|
66
|
+
# @param default [String, nil] Default value
|
67
|
+
# @return [self] for method chaining
|
68
|
+
def with_output_dir(description = 'Output directory', default = nil)
|
69
|
+
if default
|
70
|
+
desc = "#{description} (default: #{default})"
|
71
|
+
@options[:output_dir] = default unless @options.key?(:output_dir)
|
72
|
+
else
|
73
|
+
desc = "#{description} (required)"
|
74
|
+
end
|
75
|
+
@parser.on('-o DIR', '--output-dir DIR', desc) { |v| @options[:output_dir] = v }
|
76
|
+
self
|
77
|
+
end
|
78
|
+
|
79
|
+
# Add output file option to the parser
|
80
|
+
# @param description [String] Option description
|
81
|
+
# @param required [Boolean] Whether this option is required
|
82
|
+
# @return [self] for method chaining
|
83
|
+
def with_output_file(description = 'Output file', required = true)
|
84
|
+
desc = required ? "#{description} (required)" : description
|
85
|
+
@parser.on('-o FILE', '--output-file FILE', desc) { |v| @options[:output_file] = v }
|
86
|
+
self
|
87
|
+
end
|
88
|
+
|
89
|
+
# Add title option to the parser
|
90
|
+
# @return [self] for method chaining
|
91
|
+
def with_title_option
|
92
|
+
@parser.on('-t TITLE', '--title TITLE', 'Book title (required)') { |v| @options[:title] = v }
|
93
|
+
self
|
94
|
+
end
|
95
|
+
|
96
|
+
# Add author option to the parser
|
97
|
+
# @return [self] for method chaining
|
98
|
+
def with_author_option
|
99
|
+
@parser.on('-a AUTHOR', '--author AUTHOR', 'Author name (required)') { |v| @options[:author] = v }
|
100
|
+
self
|
101
|
+
end
|
102
|
+
|
103
|
+
# Add cover option to the parser
|
104
|
+
# @return [self] for method chaining
|
105
|
+
def with_cover_option
|
106
|
+
@parser.on('-c PATH', '--cover PATH', 'Cover image file path (optional)') { |v| @options[:cover_image] = v }
|
107
|
+
self
|
108
|
+
end
|
109
|
+
|
110
|
+
# Add a custom option to the parser
|
111
|
+
# @param short [String] Short option flag
|
112
|
+
# @param long [String] Long option flag
|
113
|
+
# @param description [String] Option description
|
114
|
+
# @param option_key [Symbol] Key in the options hash
|
115
|
+
# @param block [Proc] Optional block for custom processing
|
116
|
+
# @return [self] for method chaining
|
117
|
+
def with_option(short, long, description, option_key)
|
118
|
+
@parser.on(short, long, description) do |v|
|
119
|
+
@options[option_key] = block_given? ? yield(v) : v
|
120
|
+
end
|
121
|
+
self
|
122
|
+
end
|
123
|
+
|
124
|
+
# Add a custom block to configure options
|
125
|
+
# @yield [OptionParser, Hash] Yields the parser and options hash
|
126
|
+
# @return [self] for method chaining
|
127
|
+
def with_custom_options
|
128
|
+
yield @parser, @options if block_given?
|
129
|
+
self
|
130
|
+
end
|
131
|
+
|
132
|
+
# Parse the command line arguments
|
133
|
+
# @param args [Array<String>] Command line arguments
|
134
|
+
# @return [Hash] Parsed options
|
135
|
+
# @raise [SystemExit] If required options are missing
|
136
|
+
def parse(args = ARGV)
|
137
|
+
begin
|
138
|
+
@parser.parse!(args.dup)
|
139
|
+
validate_required_keys
|
140
|
+
rescue ArgumentError => e
|
141
|
+
abort "#{e.message}\n#{@parser}"
|
142
|
+
end
|
143
|
+
@options
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
# Validate that all required keys are present in the options
|
149
|
+
# @raise [SystemExit] If required options are missing
|
150
|
+
def validate_required_keys
|
151
|
+
return if @required_keys.empty?
|
152
|
+
|
153
|
+
missing = @required_keys.select { |k| @options[k].nil? }
|
154
|
+
return if missing.empty?
|
155
|
+
|
156
|
+
raise ArgumentError, "Missing required options: #{missing_keys(missing)}"
|
157
|
+
end
|
158
|
+
|
159
|
+
def missing_keys(keys)
|
160
|
+
keys.map { |k| "--#{k.to_s.tr('_', '-')}" }.join(', ')
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
require_relative 'command_registry'
|
2
|
+
require_relative 'option_builder'
|
3
|
+
|
4
|
+
module EpubTools
|
5
|
+
module CLI
|
6
|
+
# Main runner for the CLI application
|
7
|
+
class Runner
|
8
|
+
attr_reader :registry, :program_name
|
9
|
+
|
10
|
+
# Initialize a new CLI Runner
|
11
|
+
# @param program_name [String] Name of the program
|
12
|
+
def initialize(program_name = nil)
|
13
|
+
@registry = CommandRegistry.new
|
14
|
+
@program_name = program_name || File.basename($PROGRAM_NAME)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Run the CLI application
|
18
|
+
# @param args [Array<String>] Command line arguments
|
19
|
+
# @return [Boolean] true if the command was run successfully
|
20
|
+
def run(args = ARGV)
|
21
|
+
# Handle global version flag
|
22
|
+
if ['-v', '--version'].include?(args[0])
|
23
|
+
puts EpubTools::VERSION
|
24
|
+
exit 0
|
25
|
+
end
|
26
|
+
|
27
|
+
commands = registry.available_commands
|
28
|
+
|
29
|
+
if args.empty? || !commands.include?(args[0])
|
30
|
+
print_usage(commands)
|
31
|
+
exit 1
|
32
|
+
end
|
33
|
+
|
34
|
+
cmd = args.shift
|
35
|
+
handle_command(cmd, args)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Handle a specific command
|
39
|
+
# @param cmd [String] Command name
|
40
|
+
# @param args [Array<String>] Command line arguments
|
41
|
+
# @return [Boolean] true if the command was run successfully
|
42
|
+
def handle_command(cmd, args = ARGV)
|
43
|
+
command_config = registry.get(cmd)
|
44
|
+
return false unless command_config
|
45
|
+
|
46
|
+
options = command_config[:default_options].dup
|
47
|
+
required_keys = command_config[:required_keys]
|
48
|
+
|
49
|
+
builder = OptionBuilder.new(options, required_keys)
|
50
|
+
.with_banner("Usage: #{program_name} #{cmd} [options]")
|
51
|
+
.with_help_option
|
52
|
+
|
53
|
+
# Configure command-specific options
|
54
|
+
configure_command_options(cmd, builder)
|
55
|
+
|
56
|
+
# Parse arguments and run the command
|
57
|
+
options = builder.parse(args)
|
58
|
+
command_class = command_config[:class]
|
59
|
+
command_class.new(options).run
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# Print usage information
|
66
|
+
# @param commands [Array<String>] Available commands
|
67
|
+
def print_usage(_commands)
|
68
|
+
puts <<~USAGE
|
69
|
+
Usage: #{program_name} COMMAND [options]
|
70
|
+
Commands:
|
71
|
+
init Initialize a bare-bones EPUB
|
72
|
+
extract Extract XHTML files from EPUBs
|
73
|
+
split Split XHTML into separate XHTMLs per chapter
|
74
|
+
add Add chapter XHTML files into an EPUB
|
75
|
+
pack Package an EPUB directory into a .epub file
|
76
|
+
unpack Unpack an EPUB file into a directory
|
77
|
+
compile Takes EPUBs in a dir and splits, cleans, and compiles into a single EPUB.
|
78
|
+
USAGE
|
79
|
+
end
|
80
|
+
|
81
|
+
# Configure command-specific options
|
82
|
+
# @param cmd [String] Command name
|
83
|
+
# @param builder [OptionBuilder] Option builder instance
|
84
|
+
def configure_command_options(cmd, builder)
|
85
|
+
case cmd
|
86
|
+
when 'add'
|
87
|
+
builder.with_custom_options do |opts, options|
|
88
|
+
opts.on('-c DIR', '--chapters-dir DIR', 'Chapters directory (required)') { |v| options[:chapters_dir] = v }
|
89
|
+
opts.on('-e DIR', '--epub-oebps-dir DIR', 'EPUB OEBPS directory (required)') do |v|
|
90
|
+
options[:epub_oebps_dir] = v
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
when 'extract'
|
95
|
+
builder.with_custom_options do |opts, options|
|
96
|
+
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') do |v|
|
97
|
+
options[:source_dir] = v
|
98
|
+
end
|
99
|
+
opts.on('-t DIR', '--target-dir DIR',
|
100
|
+
'Directory where the XHTML files will be extracted to (required)') do |v|
|
101
|
+
options[:target_dir] = v
|
102
|
+
end
|
103
|
+
end
|
104
|
+
.with_verbose_option
|
105
|
+
|
106
|
+
when 'split'
|
107
|
+
builder.with_custom_options do |opts, options|
|
108
|
+
opts.on('-i FILE', '--input FILE', 'Source XHTML file (required)') { |v| options[:input_file] = v }
|
109
|
+
opts.on('-t TITLE', '--title TITLE', 'Book title for HTML <title> tags (required)') do |v|
|
110
|
+
options[:book_title] = v
|
111
|
+
end
|
112
|
+
opts.on('-o DIR', '--output-dir DIR',
|
113
|
+
"Output directory for chapter files (default: #{options[:output_dir]})") do |v|
|
114
|
+
options[:output_dir] = v
|
115
|
+
end
|
116
|
+
opts.on('-p PREFIX', '--prefix PREFIX',
|
117
|
+
"Filename prefix for chapters (default: #{options[:prefix]})") do |v|
|
118
|
+
options[:prefix] = v
|
119
|
+
end
|
120
|
+
end
|
121
|
+
.with_verbose_option
|
122
|
+
|
123
|
+
when 'init'
|
124
|
+
builder.with_title_option
|
125
|
+
.with_author_option
|
126
|
+
.with_custom_options do |opts, options|
|
127
|
+
opts.on('-o DIR', '--output-dir DIR', 'Destination EPUB directory (required)') do |v|
|
128
|
+
options[:destination] = v
|
129
|
+
end
|
130
|
+
end
|
131
|
+
.with_cover_option
|
132
|
+
|
133
|
+
when 'pack'
|
134
|
+
builder.with_input_dir('EPUB directory to package')
|
135
|
+
.with_output_file('Output EPUB file path')
|
136
|
+
.with_verbose_option
|
137
|
+
|
138
|
+
when 'unpack'
|
139
|
+
builder.with_custom_options do |opts, options|
|
140
|
+
opts.on('-i FILE', '--input-file FILE', 'EPUB file to unpack (required)') { |v| options[:epub_file] = v }
|
141
|
+
opts.on('-o DIR', '--output-dir DIR', 'Output directory to extract into (default: basename of epub)') do |v|
|
142
|
+
options[:output_dir] = v
|
143
|
+
end
|
144
|
+
end
|
145
|
+
.with_verbose_option
|
146
|
+
|
147
|
+
when 'compile'
|
148
|
+
builder.with_title_option
|
149
|
+
.with_author_option
|
150
|
+
.with_custom_options do |opts, options|
|
151
|
+
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') do |v|
|
152
|
+
options[:source_dir] = v
|
153
|
+
end
|
154
|
+
opts.on('-o FILE', '--output FILE', 'EPUB to create (default: book title in source dir)') do |v|
|
155
|
+
options[:output_file] = v
|
156
|
+
end
|
157
|
+
end
|
158
|
+
.with_cover_option
|
159
|
+
.with_verbose_option
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require_relative 'cli/command_registry'
|
2
|
+
require_relative 'cli/option_builder'
|
3
|
+
require_relative 'cli/runner'
|
4
|
+
|
5
|
+
module EpubTools
|
6
|
+
# CLI module - houses the object-oriented command line interface components
|
7
|
+
module CLI
|
8
|
+
# Create a new Runner instance configured with all available commands
|
9
|
+
# @param program_name [String] Name of the program
|
10
|
+
# @return [CLI::Runner] A configured runner instance
|
11
|
+
def self.create_runner(program_name = nil)
|
12
|
+
runner = Runner.new(program_name)
|
13
|
+
|
14
|
+
# Register all commands
|
15
|
+
runner.registry.register('add', EpubTools::AddChapters,
|
16
|
+
%i[chapters_dir epub_oebps_dir])
|
17
|
+
|
18
|
+
runner.registry.register('extract', EpubTools::XHTMLExtractor,
|
19
|
+
%i[source_dir target_dir],
|
20
|
+
{ verbose: true })
|
21
|
+
|
22
|
+
runner.registry.register('split', EpubTools::SplitChapters,
|
23
|
+
%i[input_file book_title],
|
24
|
+
{ output_dir: './chapters', prefix: 'chapter', verbose: true })
|
25
|
+
|
26
|
+
runner.registry.register('init', EpubTools::EpubInitializer,
|
27
|
+
%i[title author destination],
|
28
|
+
{ verbose: true })
|
29
|
+
|
30
|
+
runner.registry.register('pack', EpubTools::PackEbook,
|
31
|
+
%i[input_dir output_file],
|
32
|
+
{ verbose: true })
|
33
|
+
|
34
|
+
runner.registry.register('unpack', EpubTools::UnpackEbook,
|
35
|
+
[:epub_file],
|
36
|
+
{ verbose: true })
|
37
|
+
|
38
|
+
runner.registry.register('compile', EpubTools::CompileBook,
|
39
|
+
%i[title author source_dir],
|
40
|
+
{ verbose: true })
|
41
|
+
|
42
|
+
runner
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -1,27 +1,48 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
require 'fileutils'
|
3
|
+
require_relative 'loggable'
|
3
4
|
require_relative 'xhtml_extractor'
|
4
5
|
require_relative 'split_chapters'
|
5
6
|
require_relative 'epub_initializer'
|
6
|
-
require_relative '
|
7
|
+
require_relative 'add_chapters'
|
7
8
|
require_relative 'pack_ebook'
|
8
9
|
|
9
10
|
module EpubTools
|
10
11
|
# Orchestrates extraction, splitting, validation, and packaging of book EPUBs
|
11
12
|
class CompileBook
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
#
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
13
|
+
include Loggable
|
14
|
+
# Book title
|
15
|
+
attr_reader :title
|
16
|
+
# Book author
|
17
|
+
attr_reader :author
|
18
|
+
# Path of the input epubs
|
19
|
+
attr_reader :source_dir
|
20
|
+
# Optional path to the cover image
|
21
|
+
attr_reader :cover_image
|
22
|
+
# Filename for the final epub
|
23
|
+
attr_reader :output_file
|
24
|
+
# Optional working directory for intermediate files
|
25
|
+
attr_reader :build_dir
|
26
|
+
# Whether to print progress to STDOUT
|
27
|
+
attr_reader :verbose
|
28
|
+
|
29
|
+
# Initializes the class
|
30
|
+
# @param options [Hash] Configuration options
|
31
|
+
# @option options [String] :title Book title (required)
|
32
|
+
# @option options [String] :author Book author (required)
|
33
|
+
# @option options [String] :source_dir Path of the input epubs (required)
|
34
|
+
# @option options [String] :cover_image Optional path to the cover image
|
35
|
+
# @option options [String] :output_file Filename for the final epub (default: [title].epub)
|
36
|
+
# @option options [String] :build_dir Optional working directory for intermediate files
|
37
|
+
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
38
|
+
def initialize(options = {})
|
39
|
+
@title = options.fetch(:title)
|
40
|
+
@author = options.fetch(:author)
|
41
|
+
@source_dir = options.fetch(:source_dir)
|
42
|
+
@cover_image = options[:cover_image]
|
43
|
+
@output_file = options[:output_file] || default_output_file
|
44
|
+
@build_dir = options[:build_dir] || File.join(Dir.pwd, '.epub_tools_build')
|
45
|
+
@verbose = options[:verbose] || false
|
25
46
|
end
|
26
47
|
|
27
48
|
# Run the full compile workflow
|
@@ -40,10 +61,6 @@ module EpubTools
|
|
40
61
|
|
41
62
|
private
|
42
63
|
|
43
|
-
def log(message)
|
44
|
-
puts message if verbose
|
45
|
-
end
|
46
|
-
|
47
64
|
def default_output_file
|
48
65
|
"#{title.gsub(' ', '_')}.epub"
|
49
66
|
end
|
@@ -54,7 +71,7 @@ module EpubTools
|
|
54
71
|
end
|
55
72
|
|
56
73
|
def prepare_dirs
|
57
|
-
log
|
74
|
+
log 'Preparing build directories...'
|
58
75
|
FileUtils.mkdir_p(xhtml_dir)
|
59
76
|
FileUtils.mkdir_p(chapters_dir)
|
60
77
|
end
|
@@ -73,52 +90,78 @@ module EpubTools
|
|
73
90
|
|
74
91
|
def extract_xhtmls
|
75
92
|
log "Extracting XHTML files from epubs in '#{source_dir}'..."
|
76
|
-
XHTMLExtractor.new(
|
93
|
+
XHTMLExtractor.new({
|
94
|
+
source_dir: source_dir,
|
95
|
+
target_dir: xhtml_dir,
|
96
|
+
verbose: verbose
|
97
|
+
}).run
|
77
98
|
end
|
78
99
|
|
79
100
|
def split_xhtmls
|
80
|
-
log
|
101
|
+
log 'Splitting XHTML files into chapters...'
|
81
102
|
Dir.glob(File.join(xhtml_dir, '*.xhtml')).each do |xhtml_file|
|
82
103
|
base = File.basename(xhtml_file, '.xhtml')
|
83
104
|
log "Splitting '#{base}'..."
|
84
|
-
SplitChapters.new(
|
105
|
+
SplitChapters.new({
|
106
|
+
input_file: xhtml_file,
|
107
|
+
book_title: title,
|
108
|
+
output_dir: chapters_dir,
|
109
|
+
output_prefix: 'chapter',
|
110
|
+
verbose: verbose
|
111
|
+
}).run
|
85
112
|
end
|
86
113
|
end
|
87
114
|
|
88
115
|
def validate_sequence
|
89
|
-
log
|
116
|
+
log 'Validating chapter sequence...'
|
90
117
|
nums = Dir.glob(File.join(chapters_dir, '*.xhtml')).map do |file|
|
91
118
|
if (m = File.basename(file, '.xhtml').match(/_(\d+)\z/))
|
92
119
|
m[1].to_i
|
93
120
|
end
|
94
121
|
end.compact
|
95
122
|
raise "No chapter files found in #{chapters_dir}" if nums.empty?
|
123
|
+
|
96
124
|
sorted = nums.sort.uniq
|
97
125
|
missing = (sorted.first..sorted.last).to_a - sorted
|
98
|
-
if missing.any?
|
99
|
-
|
100
|
-
|
101
|
-
log "Chapter sequence is complete: #{sorted.first} to #{sorted.last}."
|
102
|
-
end
|
126
|
+
raise "Missing chapter numbers: #{missing.join(' ')}" if missing.any?
|
127
|
+
|
128
|
+
log "Chapter sequence is complete: #{sorted.first} to #{sorted.last}."
|
103
129
|
end
|
104
130
|
|
105
131
|
def initialize_epub
|
106
|
-
log
|
132
|
+
log 'Initializing new EPUB...'
|
107
133
|
if cover_image
|
108
|
-
EpubInitializer.new(
|
134
|
+
EpubInitializer.new({
|
135
|
+
title: title,
|
136
|
+
author: author,
|
137
|
+
destination: epub_dir,
|
138
|
+
cover_image: cover_image
|
139
|
+
}).run
|
109
140
|
else
|
110
|
-
EpubInitializer.new(
|
141
|
+
EpubInitializer.new({
|
142
|
+
title: title,
|
143
|
+
author: author,
|
144
|
+
destination: epub_dir
|
145
|
+
}).run
|
111
146
|
end
|
112
147
|
end
|
113
148
|
|
114
149
|
def add_chapters
|
115
|
-
log
|
116
|
-
|
150
|
+
log 'Adding chapters to EPUB...'
|
151
|
+
AddChapters.new({
|
152
|
+
chapters_dir: chapters_dir,
|
153
|
+
epub_dir: File.join(epub_dir, 'OEBPS'),
|
154
|
+
verbose: verbose
|
155
|
+
}).run
|
117
156
|
end
|
118
157
|
|
119
158
|
def pack_epub
|
120
159
|
log "Building final EPUB '#{output_file}'..."
|
121
|
-
PackEbook.new(
|
160
|
+
PackEbook.new({
|
161
|
+
input_dir: epub_dir,
|
162
|
+
output_file: output_file,
|
163
|
+
verbose: verbose
|
164
|
+
}).run
|
122
165
|
end
|
123
166
|
end
|
124
167
|
end
|
@@ -2,22 +2,39 @@
|
|
2
2
|
require 'fileutils'
|
3
3
|
require 'time'
|
4
4
|
require 'securerandom'
|
5
|
+
require_relative 'loggable'
|
5
6
|
|
6
7
|
module EpubTools
|
8
|
+
# Sets up a basic empty EPUB directory structure with the basic files created:
|
9
|
+
# - +mimetype+
|
10
|
+
# - +container.xml+
|
11
|
+
# - +title.xhtml+ as a title page
|
12
|
+
# - +package.opf+
|
13
|
+
# - +nav.xhtml+ as a table of contents
|
14
|
+
# - +style.css+ a basic style inherited from the repo
|
15
|
+
# - cover image (optionally)
|
7
16
|
class EpubInitializer
|
8
|
-
|
9
|
-
#
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
17
|
+
include Loggable
|
18
|
+
# Initializes the class
|
19
|
+
# @param options [Hash] Configuration options
|
20
|
+
# @option options [String] :title Book title (required)
|
21
|
+
# @option options [String] :author Book author (required)
|
22
|
+
# @option options [String] :destination Target directory for the EPUB files (required)
|
23
|
+
# @option options [String] :cover_image Optional path to the cover image
|
24
|
+
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
25
|
+
def initialize(options = {})
|
26
|
+
@title = options.fetch(:title)
|
27
|
+
@author = options.fetch(:author)
|
28
|
+
@destination = File.expand_path(options.fetch(:destination))
|
14
29
|
@uuid = "urn:uuid:#{SecureRandom.uuid}"
|
15
30
|
@modified = Time.now.utc.iso8601
|
16
|
-
@cover_image_path = cover_image
|
31
|
+
@cover_image_path = options[:cover_image]
|
17
32
|
@cover_image_fname = nil
|
18
33
|
@cover_image_media_type = nil
|
34
|
+
@verbose = options[:verbose] || false
|
19
35
|
end
|
20
36
|
|
37
|
+
# Creates the empty ebook and returns the directory
|
21
38
|
def run
|
22
39
|
create_structure
|
23
40
|
write_mimetype
|
@@ -27,6 +44,8 @@ module EpubTools
|
|
27
44
|
write_package_opf
|
28
45
|
write_nav
|
29
46
|
write_style
|
47
|
+
log "Created empty ebook structure at: #{@destination}"
|
48
|
+
@destination
|
30
49
|
end
|
31
50
|
|
32
51
|
private
|
@@ -37,7 +56,7 @@ module EpubTools
|
|
37
56
|
end
|
38
57
|
|
39
58
|
def write_mimetype
|
40
|
-
File.write("#{@destination}/mimetype",
|
59
|
+
File.write("#{@destination}/mimetype", 'application/epub+zip')
|
41
60
|
end
|
42
61
|
|
43
62
|
def write_title_page
|
@@ -114,36 +133,39 @@ module EpubTools
|
|
114
133
|
File.write(File.join(@destination, 'OEBPS', 'cover.xhtml'), content)
|
115
134
|
end
|
116
135
|
|
136
|
+
def mitem(id, href, type, properties = nil)
|
137
|
+
xml = "<item id=\"#{id}\" href=\"#{href}\" media-type=\"#{type}\""
|
138
|
+
xml += " properties=\"#{properties}\"" if properties
|
139
|
+
"#{xml}/>"
|
140
|
+
end
|
141
|
+
|
117
142
|
# Generates the package.opf with optional cover image entries
|
118
143
|
def write_package_opf
|
119
144
|
manifest_items = []
|
120
145
|
spine_items = []
|
121
|
-
|
122
|
-
manifest_items << '
|
123
|
-
manifest_items << '<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>'
|
146
|
+
manifest_items << mitem('style', 'style.css', 'text/css')
|
147
|
+
manifest_items << mitem('nav', 'nav.xhtml', 'application/xhtml+xml', 'nav')
|
124
148
|
|
125
149
|
if @cover_image_fname
|
126
|
-
manifest_items <<
|
127
|
-
manifest_items << '
|
150
|
+
manifest_items << mitem('cover-image', @cover_image_fname, @cover_image_media_type, 'cover-image')
|
151
|
+
manifest_items << mitem('cover-page', 'cover.xhtml', 'application/xhtml+xml')
|
128
152
|
spine_items << '<itemref idref="cover-page"/>'
|
129
153
|
end
|
130
154
|
|
131
|
-
manifest_items << '
|
155
|
+
manifest_items << mitem('title', 'title.xhtml', 'application/xhtml+xml')
|
132
156
|
spine_items << '<itemref idref="title"/>'
|
133
157
|
|
134
158
|
metadata = []
|
135
|
-
metadata << %
|
136
|
-
metadata << %
|
137
|
-
metadata << %
|
138
|
-
metadata <<
|
139
|
-
metadata << %
|
140
|
-
metadata << %
|
141
|
-
metadata << %
|
142
|
-
metadata << %
|
143
|
-
metadata << %
|
144
|
-
if @cover_image_fname
|
145
|
-
metadata << %Q{<meta name="cover" content="cover-image"/>}
|
146
|
-
end
|
159
|
+
metadata << %(<dc:identifier id="pub-id">#{@uuid}</dc:identifier>)
|
160
|
+
metadata << %(<dc:title>#{@title}</dc:title>)
|
161
|
+
metadata << %(<dc:creator>#{@author}</dc:creator>)
|
162
|
+
metadata << '<dc:language>en</dc:language>'
|
163
|
+
metadata << %(<meta property="dcterms:modified">#{@modified}</meta>)
|
164
|
+
metadata << %(<meta property="schema:accessMode">textual</meta>)
|
165
|
+
metadata << %(<meta property="schema:accessibilityFeature">unknown</meta>)
|
166
|
+
metadata << %(<meta property="schema:accessibilityHazard">none</meta>)
|
167
|
+
metadata << %(<meta property="schema:accessModeSufficient">textual</meta>)
|
168
|
+
metadata << %(<meta name="cover" content="cover-image"/>) if @cover_image_fname
|
147
169
|
|
148
170
|
content = <<~XML
|
149
171
|
<?xml version="1.0" encoding="utf-8"?>
|