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.
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 +17 -25
  6. data/Gemfile +5 -8
  7. data/Gemfile.lock +60 -54
  8. data/README.md +31 -4
  9. data/bin/epub-tools +1 -3
  10. data/epub_tools.gemspec +3 -4
  11. data/lib/epub_tools/add_chapters.rb +17 -18
  12. data/lib/epub_tools/append_book.rb +8 -15
  13. data/lib/epub_tools/book_builder.rb +28 -22
  14. data/lib/epub_tools/chapter_order.rb +33 -0
  15. data/lib/epub_tools/chapter_validator.rb +6 -3
  16. data/lib/epub_tools/cli.rb +121 -23
  17. data/lib/epub_tools/compile_book.rb +1 -1
  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 +22 -20
  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 +2 -1
  30. data/test/add_chapters_test.rb +64 -0
  31. data/test/append_book_test.rb +11 -11
  32. data/test/chapter_validator_test.rb +12 -0
  33. data/test/cli_test.rb +40 -3
  34. data/test/compile_book_test.rb +14 -0
  35. data/test/epub_initializer_test.rb +18 -0
  36. data/test/pdf_converter_test.rb +69 -0
  37. data/test/pdf_layout_test.rb +57 -0
  38. data/test/set_cover_test.rb +68 -0
  39. data/test/split_chapters_test.rb +18 -12
  40. data/test/style_finder_test.rb +3 -9
  41. data/test/test_helper.rb +1 -1
  42. data/test/unpack_ebook_test.rb +1 -1
  43. data/test/xhtml_cleaner_test.rb +1 -4
  44. metadata +14 -35
  45. data/lib/epub_tools/chapter_marker_detector.rb +0 -46
  46. data/lib/epub_tools/cli/command_options_configurator.rb +0 -128
  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 -114
  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
@@ -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 must implement: the book title used when splitting chapters
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
- @workspace.clean
61
- @workspace.prepare_directories
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: @workspace.xhtml_dir, verbose: verbose).run
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(@workspace.xhtml_dir, '*.xhtml')).each { |f| split_xhtml_file(f) }
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: @workspace.chapters_dir, output_prefix: 'chapter', verbose: verbose
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: @workspace.chapters_dir, verbose: verbose).validate
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: @workspace.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
- PackEbook.new(input_dir: @workspace.epub_dir, output_file: output_path, verbose: verbose).run
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
- @workspace.clean
105
+ FileUtils.rm_rf(build_dir)
103
106
  output_path
104
107
  end
105
108
 
106
- def epub_oebps_dir = File.join(@workspace.epub_dir, 'OEBPS')
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) are recognized but not required.
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
@@ -1,33 +1,131 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'cli/command_registry'
4
- require_relative 'cli/option_builder'
5
- require_relative 'cli/runner'
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
- # CLI module - houses the object-oriented command line interface components
17
+ # Command line interface: parses a command's flags and runs the class that implements it
9
18
  module CLI
10
- # Create a new Runner instance configured with all available commands
11
- # @param program_name [String] Name of the program
12
- # @return [CLI::Runner] A configured runner instance
13
- def self.create_runner(program_name = nil)
14
- runner = Runner.new(program_name)
15
- register_all_commands(runner.registry)
16
- runner
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
- # Register all available commands with their configurations
20
- # @param registry [CommandRegistry] The command registry to populate
21
- def self.register_all_commands(registry)
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: @workspace.epub_dir }
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 'xhtml_generator'
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+ a basic style inherited from the repo
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
- @config = EpubConfiguration.new(options)
32
- @verbose = @config.verbose
33
- @xhtml_generator = create_xhtml_generator
34
- @metadata_builder = create_metadata_builder
35
- @file_writer = create_file_writer
36
- end
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
- @file_writer.create_structure
57
- @file_writer.write_mimetype
58
- write_title_page
59
- @file_writer.write_container
60
- write_cover if @config.cover_image_path
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 write_title_page
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
- ext = File.extname(@config.cover_image_path).downcase
80
- media_type = determine_media_type(ext)
81
- return unless media_type
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 copy_cover_image(_ext)
116
- dest = File.join(@config.destination, 'OEBPS', @config.cover_image_fname)
117
- FileUtils.cp(@config.cover_image_path, dest)
118
- end
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
- # Generates the package.opf with optional cover image entries
127
- def write_package_opf
128
- manifest_items, spine_items = @metadata_builder.build_manifest_and_spine
129
- metadata = @metadata_builder.build_metadata
130
- content = @metadata_builder.build_opf_xml(metadata, manifest_items, spine_items)
131
- @file_writer.write_package_opf(content)
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
- # Generates the initial navigation document (Table of Contents)
135
- def write_nav
136
- content = @xhtml_generator.build_nav_page
137
- @file_writer.write_xhtml('nav.xhtml', content)
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
- default_name = "#{File.basename(@input_dir)}.epub"
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
- # Add mimetype first and uncompressed
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