epub_tools 0.5.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 +20 -24
- data/Gemfile +5 -8
- data/Gemfile.lock +60 -54
- data/README.md +65 -25
- data/bin/epub-tools +1 -3
- data/epub_tools.gemspec +3 -4
- data/lib/epub_tools/add_chapters.rb +26 -14
- data/lib/epub_tools/append_book.rb +74 -0
- data/lib/epub_tools/book_builder.rb +114 -0
- data/lib/epub_tools/chapter_order.rb +33 -0
- data/lib/epub_tools/chapter_validator.rb +20 -7
- data/lib/epub_tools/cli.rb +121 -22
- data/lib/epub_tools/compile_book.rb +12 -126
- 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 +35 -70
- 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 +4 -0
- data/test/add_chapters_test.rb +134 -0
- data/test/append_book_test.rb +127 -0
- data/test/chapter_validator_test.rb +39 -0
- data/test/cli_commands_test.rb +9 -0
- data/test/cli_test.rb +40 -3
- data/test/compile_book_test.rb +15 -2
- 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 +84 -9
- 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 +17 -34
- data/lib/epub_tools/cli/command_options_configurator.rb +0 -115
- 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 -113
- 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
|
@@ -1,54 +1,36 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'nokogiri'
|
|
5
|
-
require 'yaml'
|
|
6
4
|
require 'fileutils'
|
|
7
5
|
require_relative 'loggable'
|
|
8
6
|
require_relative 'style_finder'
|
|
9
7
|
require_relative 'xhtml_cleaner'
|
|
10
8
|
|
|
11
9
|
module EpubTools
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# - Extracts classes using {StyleFinder}[rdoc-ref:EpubTools::StyleFinder]
|
|
15
|
-
# - Looks for tags that say something like Chapter XX or Prologue and splits the text there
|
|
16
|
-
# - Creates new chapter_XX.xhtml files that are cleaned using
|
|
17
|
-
# {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner]
|
|
18
|
-
# - Saves those files to +output_dir+
|
|
10
|
+
# Splits a multi-chapter XHTML file into individual chapter files, named +chapter_N.xhtml+. A chapter starts at
|
|
11
|
+
# a "Chapter N" marker; "Chapter N (continued)" starts chapter N.5 and "Prologue" starts chapter 0.
|
|
19
12
|
class SplitChapters
|
|
20
13
|
include Loggable
|
|
21
14
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# @option options [String] :output_prefix Filename prefix for chapter files (default: 'chapter')
|
|
28
|
-
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
15
|
+
# Tags that can contain chapter markers
|
|
16
|
+
MARKER_TAGS = %w[p span h2 h3 h4].freeze
|
|
17
|
+
# Tags that can contain prologue markers
|
|
18
|
+
PROLOGUE_TAGS = %w[h3 h4].freeze
|
|
19
|
+
|
|
29
20
|
def initialize(options = {})
|
|
30
21
|
@input_file = options.fetch(:input_file)
|
|
31
22
|
@book_title = options.fetch(:book_title)
|
|
32
23
|
@output_dir = options[:output_dir] || './chapters'
|
|
33
|
-
@output_prefix = options[:output_prefix] || 'chapter'
|
|
34
24
|
@verbose = options[:verbose] || false
|
|
35
25
|
end
|
|
36
26
|
|
|
37
27
|
# Runs the splitter
|
|
38
28
|
# @return [Array<String>] List of generated chapter file paths
|
|
39
29
|
def run
|
|
40
|
-
# Prepare output dir
|
|
41
30
|
FileUtils.mkdir_p(@output_dir)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
doc = Nokogiri::HTML(raw_content)
|
|
46
|
-
|
|
47
|
-
# Find Style Classes
|
|
48
|
-
StyleFinder.new({ file_path: @input_file, verbose: @verbose }).run
|
|
49
|
-
|
|
50
|
-
chapters = extract_chapters(doc)
|
|
51
|
-
write_chapter_files(chapters)
|
|
31
|
+
doc = Nokogiri::HTML(read_and_strip_problematic_tags)
|
|
32
|
+
classes = StyleFinder.new({ file_path: @input_file, verbose: @verbose }).run
|
|
33
|
+
extract_chapters(doc).map { |number, content| write_chapter_file(number, content, classes) }
|
|
52
34
|
end
|
|
53
35
|
|
|
54
36
|
private
|
|
@@ -66,54 +48,39 @@ module EpubTools
|
|
|
66
48
|
current_number, current_fragment = process_node(node, chapters, current_number, current_fragment)
|
|
67
49
|
end
|
|
68
50
|
|
|
69
|
-
|
|
51
|
+
chapters[current_number] = current_fragment.to_html if current_number
|
|
52
|
+
chapters
|
|
70
53
|
end
|
|
71
54
|
|
|
72
55
|
def process_node(node, chapters, current_number, current_fragment)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
start_prologue(chapters, current_number, current_fragment)
|
|
56
|
+
number = marker_number(node)
|
|
57
|
+
if number
|
|
58
|
+
start_chapter(chapters, number, current_number, current_fragment)
|
|
77
59
|
else
|
|
78
60
|
current_fragment&.add_child(node.dup)
|
|
79
61
|
[current_number, current_fragment]
|
|
80
62
|
end
|
|
81
63
|
end
|
|
82
64
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
[chapter_number, Nokogiri::HTML::DocumentFragment.parse('')]
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def start_prologue(chapters, current_number, current_fragment)
|
|
94
|
-
chapters[current_number] = current_fragment.to_html if current_number
|
|
95
|
-
[0, Nokogiri::HTML::DocumentFragment.parse('')]
|
|
65
|
+
# @return [Numeric, nil] The chapter a marker node starts, or nil when the node is not a marker
|
|
66
|
+
def marker_number(node)
|
|
67
|
+
if MARKER_TAGS.include?(node.name) && (number = node.text[/Chapter\s+(\d+)/i, 1])
|
|
68
|
+
node.text.match?(/Chapter\s+\d+\s*\(continued\)/i) ? number.to_i + 0.5 : number.to_i
|
|
69
|
+
elsif PROLOGUE_TAGS.include?(node.name) && node.text.strip.match?(/\APrologue\z/i)
|
|
70
|
+
0
|
|
71
|
+
end
|
|
96
72
|
end
|
|
97
73
|
|
|
98
|
-
def
|
|
74
|
+
def start_chapter(chapters, number, current_number, current_fragment)
|
|
99
75
|
chapters[current_number] = current_fragment.to_html if current_number
|
|
100
|
-
|
|
76
|
+
[number, Nokogiri::HTML::DocumentFragment.parse('')]
|
|
101
77
|
end
|
|
102
78
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
end
|
|
109
|
-
chapter_files
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def write_chapter_file(label, content)
|
|
113
|
-
display_label = display_label(label)
|
|
114
|
-
filename = File.join(@output_dir, "#{@output_prefix}_#{label}.xhtml")
|
|
115
|
-
File.write(filename, build_xhtml_template(display_label, content))
|
|
116
|
-
XHTMLCleaner.new({ filename: filename }).run
|
|
79
|
+
def write_chapter_file(label, content, classes)
|
|
80
|
+
display = display_label(label)
|
|
81
|
+
filename = File.join(@output_dir, "chapter_#{file_label(label)}.xhtml")
|
|
82
|
+
File.write(filename, build_xhtml_template(display, content))
|
|
83
|
+
XHTMLCleaner.new({ filename: filename, classes: classes }).run
|
|
117
84
|
log("Extracted: #{filename}")
|
|
118
85
|
filename
|
|
119
86
|
end
|
|
@@ -123,7 +90,7 @@ module EpubTools
|
|
|
123
90
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
124
91
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
125
92
|
<head>
|
|
126
|
-
<title>#{@book_title} - #{display_label}</title>
|
|
93
|
+
<title>#{@book_title.encode(xml: :text)} - #{display_label}</title>
|
|
127
94
|
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
128
95
|
</head>
|
|
129
96
|
<body>
|
|
@@ -134,16 +101,14 @@ module EpubTools
|
|
|
134
101
|
HTML
|
|
135
102
|
end
|
|
136
103
|
|
|
137
|
-
def
|
|
138
|
-
label.
|
|
104
|
+
def file_label(label)
|
|
105
|
+
label.is_a?(Float) ? label.to_s.gsub('.', '_') : label.to_s
|
|
139
106
|
end
|
|
140
107
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return false unless %w[h3 h4].include?(node.name)
|
|
144
|
-
return false unless node.text.strip =~ /\APrologue\z/i
|
|
108
|
+
def display_label(label)
|
|
109
|
+
return 'Prologue' if label.zero?
|
|
145
110
|
|
|
146
|
-
|
|
111
|
+
"Chapter #{label}"
|
|
147
112
|
end
|
|
148
113
|
end
|
|
149
114
|
end
|
|
@@ -1,69 +1,40 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'nokogiri'
|
|
5
|
-
require 'yaml'
|
|
6
4
|
require_relative 'loggable'
|
|
7
5
|
|
|
8
6
|
module EpubTools
|
|
9
7
|
# Finds css classes for bold and italic texts in Google Docs-generated EPUBs. Used by
|
|
10
|
-
# {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner]
|
|
11
|
-
#
|
|
8
|
+
# {SplitChapters}[rdoc-ref:EpubTools::SplitChapters] to tell {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner]
|
|
9
|
+
# which spans are bold or italic.
|
|
12
10
|
class StyleFinder
|
|
13
11
|
include Loggable
|
|
14
12
|
|
|
15
13
|
# Initializes the class
|
|
16
14
|
# @param options [Hash] Configuration options
|
|
17
15
|
# @option options [String] :file_path XHTML file to be analyzed (required)
|
|
18
|
-
# @option options [String] :output_path Path to write the YAML file (default: 'text_style_classes.yaml')
|
|
19
16
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
20
17
|
def initialize(options = {})
|
|
21
18
|
@file_path = options.fetch(:file_path)
|
|
22
|
-
@output_path = options[:output_path] || 'text_style_classes.yaml'
|
|
23
19
|
@verbose = options[:verbose] || false
|
|
24
20
|
raise ArgumentError, "File does not exist: #{@file_path}" unless File.exist?(@file_path)
|
|
25
21
|
end
|
|
26
22
|
|
|
27
23
|
# Runs the finder
|
|
28
|
-
# @return [Hash
|
|
24
|
+
# @return [Hash{Symbol => Array<String>}] The +:italics+ and +:bolds+ class names
|
|
29
25
|
def run
|
|
30
|
-
|
|
31
|
-
italics
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
doc = Nokogiri::HTML(File.read(@file_path))
|
|
37
|
-
doc.xpath('//style').map(&:text).join("\n")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def extract_style_classes(style_blocks)
|
|
41
|
-
italics = extract_classes(style_blocks, /font-style\s*:\s*italic/)
|
|
42
|
-
bolds = extract_classes(style_blocks, /font-weight\s*:\s*700/)
|
|
43
|
-
[italics, bolds]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def generate_output(italics, bolds)
|
|
47
|
-
print_summary(italics, bolds) if @verbose
|
|
48
|
-
|
|
49
|
-
data = { 'italics' => italics, 'bolds' => bolds }
|
|
50
|
-
File.write(@output_path, data.to_yaml)
|
|
51
|
-
data
|
|
26
|
+
styles = Nokogiri::HTML(File.read(@file_path)).xpath('//style').map(&:text).join("\n")
|
|
27
|
+
italics = classes_with(styles, /font-style\s*:\s*italic/)
|
|
28
|
+
bolds = classes_with(styles, /font-weight\s*:\s*700/)
|
|
29
|
+
log "Classes with font-style: italic: #{italics.join(', ')}" unless italics.empty?
|
|
30
|
+
log "Classes with font-weight: 700: #{bolds.join(', ')}" unless bolds.empty?
|
|
31
|
+
{ italics: italics, bolds: bolds }
|
|
52
32
|
end
|
|
53
33
|
|
|
54
34
|
private
|
|
55
35
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
style_text.scan(regex).flatten.uniq
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def print_summary(italics, bolds)
|
|
62
|
-
log "Classes with font-style: italic: #{italics.join(', ')}" unless italics.empty?
|
|
63
|
-
|
|
64
|
-
return if bolds.empty?
|
|
65
|
-
|
|
66
|
-
log "Classes with font-weight: 700: #{bolds.join(', ')}"
|
|
36
|
+
def classes_with(styles, pattern)
|
|
37
|
+
styles.scan(/\.([\w-]+)\s*{[^}]*#{pattern.source}[^}]*}/i).flatten.uniq
|
|
67
38
|
end
|
|
68
39
|
end
|
|
69
40
|
end
|
|
@@ -16,8 +16,7 @@ module EpubTools
|
|
|
16
16
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
17
17
|
def initialize(options = {})
|
|
18
18
|
@epub_file = File.expand_path(options.fetch(:epub_file))
|
|
19
|
-
output_dir = options[:output_dir]
|
|
20
|
-
@output_dir = output_dir.nil? || output_dir.empty? ? default_dir : output_dir
|
|
19
|
+
@output_dir = options[:output_dir].to_s.empty? ? default_dir : options[:output_dir]
|
|
21
20
|
@verbose = options[:verbose] || false
|
|
22
21
|
end
|
|
23
22
|
|
|
@@ -26,30 +25,13 @@ module EpubTools
|
|
|
26
25
|
def run
|
|
27
26
|
validate!
|
|
28
27
|
FileUtils.mkdir_p(@output_dir)
|
|
29
|
-
|
|
28
|
+
Zip::File.extract_all(@epub_file, @output_dir) { true }
|
|
30
29
|
log "Unpacked #{File.basename(@epub_file)} to #{@output_dir}"
|
|
31
30
|
@output_dir
|
|
32
31
|
end
|
|
33
32
|
|
|
34
33
|
private
|
|
35
34
|
|
|
36
|
-
def extract_entries
|
|
37
|
-
Zip::File.open(@epub_file) do |zip|
|
|
38
|
-
zip.each do |entry|
|
|
39
|
-
extract_entry(entry)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def extract_entry(entry)
|
|
45
|
-
if entry.directory?
|
|
46
|
-
FileUtils.mkdir_p(File.join(@output_dir, entry.name))
|
|
47
|
-
else
|
|
48
|
-
FileUtils.mkdir_p(File.join(@output_dir, File.dirname(entry.name)))
|
|
49
|
-
entry.extract(destination_directory: @output_dir) { true }
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
35
|
def default_dir
|
|
54
36
|
[File.dirname(@epub_file), File.basename(@epub_file, '.epub')].join('/')
|
|
55
37
|
end
|
data/lib/epub_tools/version.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'nokogiri'
|
|
5
|
-
require 'yaml'
|
|
6
4
|
|
|
7
5
|
module EpubTools
|
|
8
6
|
# Cleans Google Docs XHTMLs
|
|
@@ -12,7 +10,7 @@ module EpubTools
|
|
|
12
10
|
#
|
|
13
11
|
# - Removes any <tt><br /></tt> or <tt><hr /></tt> tags.
|
|
14
12
|
# - Removes empty <tt><p></tt> tags.
|
|
15
|
-
# - Using the +
|
|
13
|
+
# - Using the +classes+, it removes <tt><span></tt> tags that are used for bold or italics and
|
|
16
14
|
# replaces them with <tt><b></tt> or <tt><i></tt> tags.
|
|
17
15
|
# - Unwraps any <tt><span></tt> tags that have no classes assigned.
|
|
18
16
|
# - Outputs everything to a cleanly formatted +.xhtml+
|
|
@@ -20,12 +18,11 @@ module EpubTools
|
|
|
20
18
|
# Initializes the class
|
|
21
19
|
# @param options [Hash] Configuration options
|
|
22
20
|
# @option options [String] :filename The path to the xhtml to clean (required)
|
|
23
|
-
# @option options [String] :
|
|
24
|
-
#
|
|
21
|
+
# @option options [Hash{Symbol => Array<String>}] :classes The +:italics+ and +:bolds+ class names, as found by
|
|
22
|
+
# {StyleFinder}[rdoc-ref:EpubTools::StyleFinder] (required)
|
|
25
23
|
def initialize(options = {})
|
|
26
24
|
@filename = options.fetch(:filename)
|
|
27
|
-
|
|
28
|
-
@classes = YAML.load_file(class_config).transform_keys(&:to_sym)
|
|
25
|
+
@classes = options.fetch(:classes)
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
# Runs the cleaner
|
|
@@ -24,12 +24,7 @@ module EpubTools
|
|
|
24
24
|
# Runs the extraction process
|
|
25
25
|
# @return [Array<String>] Paths to all extracted XHTML files
|
|
26
26
|
def run
|
|
27
|
-
|
|
28
|
-
epub_files.each do |epub_path|
|
|
29
|
-
extracted = extract_xhtmls_from(epub_path)
|
|
30
|
-
all_extracted_files.concat(extracted) if extracted
|
|
31
|
-
end
|
|
32
|
-
all_extracted_files
|
|
27
|
+
epub_files.flat_map { |epub_path| extract_xhtmls_from(epub_path) || [] }
|
|
33
28
|
end
|
|
34
29
|
|
|
35
30
|
private
|
|
@@ -56,7 +51,6 @@ module EpubTools
|
|
|
56
51
|
|
|
57
52
|
renamed = "#{epub_name}_#{File.basename(entry.name)}"
|
|
58
53
|
output_path = File.join(@target_dir, renamed)
|
|
59
|
-
FileUtils.mkdir_p(File.dirname(output_path))
|
|
60
54
|
entry.extract(renamed, destination_directory: @target_dir) { true }
|
|
61
55
|
log output_path
|
|
62
56
|
extracted_files << output_path
|
data/lib/epub_tools.rb
CHANGED
|
@@ -7,9 +7,13 @@ require_relative 'epub_tools/epub_initializer'
|
|
|
7
7
|
require_relative 'epub_tools/split_chapters'
|
|
8
8
|
require_relative 'epub_tools/xhtml_cleaner'
|
|
9
9
|
require_relative 'epub_tools/xhtml_extractor'
|
|
10
|
+
require_relative 'epub_tools/pdf_converter'
|
|
10
11
|
require_relative 'epub_tools/pack_ebook'
|
|
11
12
|
require_relative 'epub_tools/unpack_ebook'
|
|
13
|
+
require_relative 'epub_tools/book_builder'
|
|
12
14
|
require_relative 'epub_tools/compile_book'
|
|
15
|
+
require_relative 'epub_tools/append_book'
|
|
16
|
+
require_relative 'epub_tools/set_cover'
|
|
13
17
|
require_relative 'epub_tools/cli'
|
|
14
18
|
|
|
15
19
|
# Wrapper for all the other classes
|
data/test/add_chapters_test.rb
CHANGED
|
@@ -121,3 +121,137 @@ class AddChaptersTest < Minitest::Test
|
|
|
121
121
|
nav_doc.xpath('//nav/ol/li/a')
|
|
122
122
|
end
|
|
123
123
|
end
|
|
124
|
+
|
|
125
|
+
class AddChaptersHalfChapterTest < Minitest::Test
|
|
126
|
+
def setup
|
|
127
|
+
@tmp = Dir.mktmpdir
|
|
128
|
+
@chapters_dir = File.join(@tmp, 'chapters')
|
|
129
|
+
@epub_dir = File.join(@tmp, 'OEBPS')
|
|
130
|
+
Dir.mkdir(@chapters_dir)
|
|
131
|
+
Dir.mkdir(@epub_dir)
|
|
132
|
+
create_chapter_files
|
|
133
|
+
create_opf_and_nav
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def teardown
|
|
137
|
+
FileUtils.remove_entry(@tmp)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_half_chapter_sorting_and_labels
|
|
141
|
+
result = EpubTools::AddChapters.new(chapters_dir: @chapters_dir, oebps_dir: @epub_dir).run
|
|
142
|
+
|
|
143
|
+
assert_equal %w[chapter_1.xhtml chapter_1_5.xhtml chapter_2.xhtml], result
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_half_chapter_nav_label
|
|
147
|
+
EpubTools::AddChapters.new(chapters_dir: @chapters_dir, oebps_dir: @epub_dir).run
|
|
148
|
+
|
|
149
|
+
nav_doc = Nokogiri::XML(File.read(File.join(@epub_dir, 'nav.xhtml')))
|
|
150
|
+
nav_doc.remove_namespaces!
|
|
151
|
+
links = nav_doc.xpath('//nav/ol/li/a')
|
|
152
|
+
|
|
153
|
+
assert_equal 'Chapter 1', links[0].text
|
|
154
|
+
assert_equal 'Chapter 1.5', links[1].text
|
|
155
|
+
assert_equal 'Chapter 2', links[2].text
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def test_half_chapter_opf_id
|
|
159
|
+
EpubTools::AddChapters.new(chapters_dir: @chapters_dir, oebps_dir: @epub_dir).run
|
|
160
|
+
|
|
161
|
+
doc = Nokogiri::XML(File.read(File.join(@epub_dir, 'package.opf')))
|
|
162
|
+
ids = doc.xpath('//xmlns:manifest/xmlns:item').map { |i| i['id'] }
|
|
163
|
+
|
|
164
|
+
assert_includes ids, 'chap1_5'
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def create_chapter_files
|
|
170
|
+
File.write(File.join(@chapters_dir, 'chapter_1.xhtml'), '<html><body>Ch1</body></html>')
|
|
171
|
+
File.write(File.join(@chapters_dir, 'chapter_1_5.xhtml'), '<html><body>Ch1.5</body></html>')
|
|
172
|
+
File.write(File.join(@chapters_dir, 'chapter_2.xhtml'), '<html><body>Ch2</body></html>')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def create_opf_and_nav
|
|
176
|
+
File.write(File.join(@epub_dir, 'package.opf'), <<~XML)
|
|
177
|
+
<?xml version="1.0"?>
|
|
178
|
+
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="en">
|
|
179
|
+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"></metadata>
|
|
180
|
+
<manifest></manifest>
|
|
181
|
+
<spine></spine>
|
|
182
|
+
</package>
|
|
183
|
+
XML
|
|
184
|
+
File.write(File.join(@epub_dir, 'nav.xhtml'), <<~XHTML)
|
|
185
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
186
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
|
|
187
|
+
<body>
|
|
188
|
+
<nav epub:type="toc" id="toc"><ol></ol></nav>
|
|
189
|
+
</body>
|
|
190
|
+
</html>
|
|
191
|
+
XHTML
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class AddChaptersReadingOrderTest < Minitest::Test
|
|
196
|
+
def setup
|
|
197
|
+
@tmp = Dir.mktmpdir
|
|
198
|
+
@chapters_dir = File.join(@tmp, 'chapters')
|
|
199
|
+
@epub_dir = File.join(@tmp, 'OEBPS')
|
|
200
|
+
FileUtils.mkdir_p([@chapters_dir, @epub_dir])
|
|
201
|
+
create_book_with_two_chapters
|
|
202
|
+
write_chapter('chapter_1b.xhtml', '<h1>Chapter 1b: Later</h1>')
|
|
203
|
+
write_chapter('chapter_1a.xhtml', '<h1>Chapter 1a: Side</h1>')
|
|
204
|
+
write_chapter('chapter_3.xhtml', '<p>No heading</p>')
|
|
205
|
+
write_chapter('chapter_epilogue.xhtml', '<h1>Epilogue: After</h1>')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def teardown
|
|
209
|
+
FileUtils.remove_entry(@tmp)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def test_inserts_chapters_in_reading_order_with_their_headings
|
|
213
|
+
EpubTools::AddChapters.new(chapters_dir: @chapters_dir, oebps_dir: @epub_dir).run
|
|
214
|
+
|
|
215
|
+
opf = Nokogiri::XML(File.read(File.join(@epub_dir, 'package.opf')))
|
|
216
|
+
nav = Nokogiri::XML(File.read(File.join(@epub_dir, 'nav.xhtml'))).remove_namespaces!
|
|
217
|
+
spine = opf.xpath('//xmlns:itemref').map { |i| i['idref'] }
|
|
218
|
+
|
|
219
|
+
assert_equal %w[title chap1 chapter_1a chapter_1b chap2 chap3 chapter_epilogue], spine
|
|
220
|
+
assert_equal ['Title Page', 'Chapter 1', 'Chapter 1a: Side', 'Chapter 1b: Later', 'Chapter 2', 'Chapter 3',
|
|
221
|
+
'Epilogue: After'],
|
|
222
|
+
nav.xpath('//nav/ol/li/a').map(&:text)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def write_chapter(name, body)
|
|
228
|
+
File.write(File.join(@chapters_dir, name), "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>#{body}</body></html>")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def create_book_with_two_chapters
|
|
232
|
+
File.write(File.join(@epub_dir, 'package.opf'), <<~XML)
|
|
233
|
+
<?xml version="1.0"?>
|
|
234
|
+
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="en">
|
|
235
|
+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"></metadata>
|
|
236
|
+
<manifest>
|
|
237
|
+
<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>
|
|
238
|
+
<item id="chap1" href="chapter_1.xhtml" media-type="application/xhtml+xml"/>
|
|
239
|
+
<item id="chap2" href="chapter_2.xhtml" media-type="application/xhtml+xml"/>
|
|
240
|
+
</manifest>
|
|
241
|
+
<spine><itemref idref="title"/><itemref idref="chap1"/><itemref idref="chap2"/></spine>
|
|
242
|
+
</package>
|
|
243
|
+
XML
|
|
244
|
+
File.write(File.join(@epub_dir, 'nav.xhtml'), <<~XHTML)
|
|
245
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
246
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
|
|
247
|
+
<body>
|
|
248
|
+
<nav epub:type="toc" id="toc"><ol>
|
|
249
|
+
<li><a href="title.xhtml">Title Page</a></li>
|
|
250
|
+
<li><a href="chapter_1.xhtml">Chapter 1</a></li>
|
|
251
|
+
<li><a href="chapter_2.xhtml">Chapter 2</a></li>
|
|
252
|
+
</ol></nav>
|
|
253
|
+
</body>
|
|
254
|
+
</html>
|
|
255
|
+
XHTML
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_helper'
|
|
4
|
+
require_relative '../lib/epub_tools/append_book'
|
|
5
|
+
|
|
6
|
+
class AppendBookTest < Minitest::Test
|
|
7
|
+
def setup
|
|
8
|
+
@tmp = Dir.mktmpdir
|
|
9
|
+
@source = File.join(@tmp, 'src')
|
|
10
|
+
@target = File.join(@tmp, 'target.epub')
|
|
11
|
+
FileUtils.mkdir_p(@source)
|
|
12
|
+
FileUtils.touch(@target)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def teardown
|
|
16
|
+
FileUtils.rm_rf(@tmp)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_initialize_assigns_attributes
|
|
20
|
+
ab = build_append_book(verbose: true)
|
|
21
|
+
|
|
22
|
+
assert_equal @source, ab.source_dir
|
|
23
|
+
assert_equal File.expand_path(@target), ab.target_epub
|
|
24
|
+
assert_equal @tmp, ab.build_dir
|
|
25
|
+
assert ab.verbose
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_default_build_dir
|
|
29
|
+
ab = EpubTools::AppendBook.new(source_dir: @source, target_epub: @target)
|
|
30
|
+
|
|
31
|
+
assert ab.build_dir.end_with?('.epub_tools_build')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_log_outputs_when_verbose
|
|
35
|
+
ab = build_append_book(verbose: true)
|
|
36
|
+
|
|
37
|
+
assert_output("hello\n") { ab.send(:log, 'hello') }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_log_silent_when_not_verbose
|
|
41
|
+
ab = build_append_book(verbose: false)
|
|
42
|
+
|
|
43
|
+
assert_silent { ab.send(:log, 'hello') }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_detect_conflicts_raises_on_overlap
|
|
47
|
+
ab = build_append_book
|
|
48
|
+
setup_conflict_dirs(new_chapters: [1, 2], existing_chapters: [1])
|
|
49
|
+
|
|
50
|
+
error = assert_raises(ArgumentError) { ab.send(:detect_conflicts) }
|
|
51
|
+
assert_match(/chapters 1 already exist/, error.message)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_detect_conflicts_passes_with_no_overlap
|
|
55
|
+
ab = build_append_book
|
|
56
|
+
setup_conflict_dirs(new_chapters: [5], existing_chapters: [1, 2])
|
|
57
|
+
|
|
58
|
+
ab.send(:detect_conflicts)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_detect_conflicts_with_lettered_chapters
|
|
62
|
+
ab = build_append_book
|
|
63
|
+
setup_conflict_dirs(new_chapters: %w[3a 4], existing_chapters: %w[3 3a])
|
|
64
|
+
|
|
65
|
+
error = assert_raises(ArgumentError) { ab.send(:detect_conflicts) }
|
|
66
|
+
assert_match(/chapters 3a already exist/, error.message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_detect_conflicts_with_half_chapters
|
|
70
|
+
ab = build_append_book
|
|
71
|
+
chapters_dir = File.join(@tmp, 'chapters')
|
|
72
|
+
oebps_dir = File.join(@tmp, 'epub', 'OEBPS')
|
|
73
|
+
FileUtils.mkdir_p([chapters_dir, oebps_dir])
|
|
74
|
+
|
|
75
|
+
FileUtils.touch(File.join(chapters_dir, 'chapter_3_5.xhtml'))
|
|
76
|
+
FileUtils.touch(File.join(oebps_dir, 'chapter_3_5.xhtml'))
|
|
77
|
+
|
|
78
|
+
error = assert_raises(ArgumentError) { ab.send(:detect_conflicts) }
|
|
79
|
+
assert_match(/3\.5 already exist/, error.message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_run_completes_workflow
|
|
83
|
+
ab = build_append_book
|
|
84
|
+
|
|
85
|
+
def ab.prepare_epub; end
|
|
86
|
+
def ab.extract_xhtmls; end
|
|
87
|
+
def ab.split_xhtmls; end
|
|
88
|
+
def ab.validate_chapters; end
|
|
89
|
+
def ab.before_add_chapters; end
|
|
90
|
+
def ab.add_chapters; end
|
|
91
|
+
def ab.pack_epub; end
|
|
92
|
+
|
|
93
|
+
result = ab.run
|
|
94
|
+
|
|
95
|
+
assert_equal File.expand_path(@target), result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_backup_creates_bak_file
|
|
99
|
+
File.write(@target, 'epub content')
|
|
100
|
+
ab = build_append_book
|
|
101
|
+
|
|
102
|
+
ab.send(:backup_target)
|
|
103
|
+
|
|
104
|
+
backup_path = "#{File.expand_path(@target)}.bak"
|
|
105
|
+
|
|
106
|
+
assert_path_exists backup_path
|
|
107
|
+
assert_equal 'epub content', File.read(backup_path)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def build_append_book(verbose: false)
|
|
113
|
+
EpubTools::AppendBook.new(
|
|
114
|
+
source_dir: @source, target_epub: @target,
|
|
115
|
+
build_dir: @tmp, verbose: verbose
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def setup_conflict_dirs(new_chapters:, existing_chapters:)
|
|
120
|
+
chapters_dir = File.join(@tmp, 'chapters')
|
|
121
|
+
oebps_dir = File.join(@tmp, 'epub', 'OEBPS')
|
|
122
|
+
FileUtils.mkdir_p([chapters_dir, oebps_dir])
|
|
123
|
+
|
|
124
|
+
new_chapters.each { |n| FileUtils.touch(File.join(chapters_dir, "chapter_#{n}.xhtml")) }
|
|
125
|
+
existing_chapters.each { |n| FileUtils.touch(File.join(oebps_dir, "chapter_#{n}.xhtml")) }
|
|
126
|
+
end
|
|
127
|
+
end
|