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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -8
  3. data/.gitignore +0 -1
  4. data/.tool-versions +1 -1
  5. data/CLAUDE.md +20 -24
  6. data/Gemfile +5 -8
  7. data/Gemfile.lock +60 -54
  8. data/README.md +65 -25
  9. data/bin/epub-tools +1 -3
  10. data/epub_tools.gemspec +3 -4
  11. data/lib/epub_tools/add_chapters.rb +26 -14
  12. data/lib/epub_tools/append_book.rb +74 -0
  13. data/lib/epub_tools/book_builder.rb +114 -0
  14. data/lib/epub_tools/chapter_order.rb +33 -0
  15. data/lib/epub_tools/chapter_validator.rb +20 -7
  16. data/lib/epub_tools/cli.rb +121 -22
  17. data/lib/epub_tools/compile_book.rb +12 -126
  18. data/lib/epub_tools/epub_initializer.rb +93 -99
  19. data/lib/epub_tools/pack_ebook.rb +2 -14
  20. data/lib/epub_tools/pdf_converter.rb +113 -0
  21. data/lib/epub_tools/pdf_layout.rb +116 -0
  22. data/lib/epub_tools/set_cover.rb +116 -0
  23. data/lib/epub_tools/split_chapters.rb +35 -70
  24. data/lib/epub_tools/style_finder.rb +11 -40
  25. data/lib/epub_tools/unpack_ebook.rb +2 -20
  26. data/lib/epub_tools/version.rb +1 -1
  27. data/lib/epub_tools/xhtml_cleaner.rb +4 -7
  28. data/lib/epub_tools/xhtml_extractor.rb +1 -7
  29. data/lib/epub_tools.rb +4 -0
  30. data/test/add_chapters_test.rb +134 -0
  31. data/test/append_book_test.rb +127 -0
  32. data/test/chapter_validator_test.rb +39 -0
  33. data/test/cli_commands_test.rb +9 -0
  34. data/test/cli_test.rb +40 -3
  35. data/test/compile_book_test.rb +15 -2
  36. data/test/epub_initializer_test.rb +18 -0
  37. data/test/pdf_converter_test.rb +69 -0
  38. data/test/pdf_layout_test.rb +57 -0
  39. data/test/set_cover_test.rb +68 -0
  40. data/test/split_chapters_test.rb +84 -9
  41. data/test/style_finder_test.rb +3 -9
  42. data/test/test_helper.rb +1 -1
  43. data/test/unpack_ebook_test.rb +1 -1
  44. data/test/xhtml_cleaner_test.rb +1 -4
  45. metadata +17 -34
  46. data/lib/epub_tools/cli/command_options_configurator.rb +0 -115
  47. data/lib/epub_tools/cli/command_registry.rb +0 -49
  48. data/lib/epub_tools/cli/option_builder.rb +0 -166
  49. data/lib/epub_tools/cli/runner.rb +0 -113
  50. data/lib/epub_tools/compile_workspace.rb +0 -40
  51. data/lib/epub_tools/epub_configuration.rb +0 -33
  52. data/lib/epub_tools/epub_file_writer.rb +0 -57
  53. data/lib/epub_tools/epub_metadata_builder.rb +0 -92
  54. data/lib/epub_tools/xhtml_generator.rb +0 -71
  55. data/test/cli/command_registry_test.rb +0 -68
  56. data/test/cli/option_builder_test.rb +0 -183
  57. data/test/cli/runner_test.rb +0 -91
  58. 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
- # Takes a Google Docs generated, already extracted from their EPUB, XHTML files with multiple
13
- # chapters and it:
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
- # Initializes the class
23
- # @param options [Hash] Configuration options
24
- # @option options [String] :input_file Path to the source XHTML (required)
25
- # @option options [String] :book_title Title to use in HTML <title> tags (required)
26
- # @option options [String] :output_dir Where to write chapter files (default: './chapters')
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
- # Read the doc
44
- raw_content = read_and_strip_problematic_tags
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
- finalize_chapters(chapters, current_number, current_fragment)
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
- if chapter_marker?(node)
74
- start_new_chapter(chapters, node, current_number, current_fragment)
75
- elsif prologue_marker?(node)
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
- def chapter_marker?(node)
84
- node.text.match?(/Chapter\s+\d+/i) && %w[p span h2 h3 h4].include?(node.name)
85
- end
86
-
87
- def start_new_chapter(chapters, node, current_number, current_fragment)
88
- chapters[current_number] = current_fragment.to_html if current_number
89
- chapter_number = node.text.match(/Chapter\s+(\d+)/i)[1].to_i
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 finalize_chapters(chapters, current_number, current_fragment)
74
+ def start_chapter(chapters, number, current_number, current_fragment)
99
75
  chapters[current_number] = current_fragment.to_html if current_number
100
- chapters
76
+ [number, Nokogiri::HTML::DocumentFragment.parse('')]
101
77
  end
102
78
 
103
- def write_chapter_files(chapters)
104
- chapter_files = []
105
- chapters.each do |number, content|
106
- filename = write_chapter_file(number, content)
107
- chapter_files << filename
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 display_label(label)
138
- label.positive? ? "Chapter #{label}" : 'Prologue'
104
+ def file_label(label)
105
+ label.is_a?(Float) ? label.to_s.gsub('.', '_') : label.to_s
139
106
  end
140
107
 
141
- # Detect a bolded Prologue marker
142
- def prologue_marker?(node)
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
- true
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] and
11
- # {SplitChapters}[rdoc-ref:EpubTools::SplitChapters].
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] Data containing the extracted style classes (italics and bolds)
24
+ # @return [Hash{Symbol => Array<String>}] The +:italics+ and +:bolds+ class names
29
25
  def run
30
- style_blocks = extract_style_blocks
31
- italics, bolds = extract_style_classes(style_blocks)
32
- generate_output(italics, bolds)
33
- end
34
-
35
- def extract_style_blocks
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 extract_classes(style_text, pattern)
57
- regex = /\.([\w-]+)\s*{[^}]*#{pattern.source}[^}]*}/i
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
- extract_entries
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module EpubTools
4
4
  # Ruby Gem version number
5
- VERSION = '0.5.0'
5
+ VERSION = '0.9.0'
6
6
  end
@@ -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 +class_config+, it removes <tt><span></tt> tags that are used for bold or italics and
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] :class_config Path to a YAML file containing the bold and italic classes to check
24
- # (default: 'text_style_classes.yaml')
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
- class_config = options[:class_config] || 'text_style_classes.yaml'
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
- all_extracted_files = []
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
@@ -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