epub_tools 0.2.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 +7 -0
- data/.github/workflows/ci.yml +21 -0
- data/.gitignore +7 -0
- data/.nova/Configuration.json +4 -0
- data/.ruby-version +1 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +53 -0
- data/README.md +118 -0
- data/Rakefile +9 -0
- data/bin/epub-tools +107 -0
- data/epub_tools.gemspec +21 -0
- data/lib/epub_tools/add_chapters_to_epub.rb +87 -0
- data/lib/epub_tools/cli_helper.rb +31 -0
- data/lib/epub_tools/compile_book.rb +121 -0
- data/lib/epub_tools/epub_initializer.rb +197 -0
- data/lib/epub_tools/pack_ebook.rb +60 -0
- data/lib/epub_tools/split_chapters.rb +105 -0
- data/lib/epub_tools/text_style_class_finder.rb +47 -0
- data/lib/epub_tools/unpack_ebook.rb +46 -0
- data/lib/epub_tools/version.rb +3 -0
- data/lib/epub_tools/xhtml_cleaner.rb +75 -0
- data/lib/epub_tools/xhtml_extractor.rb +46 -0
- data/lib/epub_tools.rb +12 -0
- data/style.css +99 -0
- data/test/add_chapters_to_epub_test.rb +92 -0
- data/test/compile_book_test.rb +193 -0
- data/test/epub_initializer_test.rb +55 -0
- data/test/pack_ebook_test.rb +68 -0
- data/test/split_chapters_test.rb +53 -0
- data/test/test_helper.rb +9 -0
- data/test/text_style_class_finder_test.rb +40 -0
- data/test/unpack_ebook_test.rb +58 -0
- data/test/xhtml_cleaner_test.rb +39 -0
- data/test/xhtml_extractor_test.rb +31 -0
- metadata +142 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: ea6ef571bfc4a850094975f5bf56be8d56b4a81b5cf977dc7a6fca295005086a
|
4
|
+
data.tar.gz: 37b32d7759ac7b00f273871558778cfe0c613955e7319dbee8f33e590678eb94
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9ea5b766003d9595f9d067e4dbfea5a6348102deb29092be235fadb3b511feec1dff20c1bc63683673378e1136e70f026c7a747c54793a329e7a136b9d18b4d3
|
7
|
+
data.tar.gz: 411cf6f8b5e7cde261368076e55ada115df9f8884bf951b0b3ea2a2ab0fa77425ecf2a04e3de63e3c1351f2bd862c0bb7f0296781c1e78ed9d7c4cea73e02e76
|
@@ -0,0 +1,21 @@
|
|
1
|
+
name: CI
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: ["main"]
|
6
|
+
pull_request:
|
7
|
+
branches: ["main"]
|
8
|
+
|
9
|
+
jobs:
|
10
|
+
test:
|
11
|
+
runs-on: ubuntu-latest
|
12
|
+
steps:
|
13
|
+
- uses: actions/checkout@v3
|
14
|
+
- uses: ruby/setup-ruby@v1
|
15
|
+
with:
|
16
|
+
ruby-version: '3.4.3'
|
17
|
+
bundler-cache: true
|
18
|
+
- name: Install dependencies
|
19
|
+
run: bundle install --jobs 4 --retry 3
|
20
|
+
- name: Run tests
|
21
|
+
run: bundle exec rake test
|
data/.gitignore
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
3.4.3
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
GEM
|
2
|
+
remote: https://rubygems.org/
|
3
|
+
specs:
|
4
|
+
docile (1.4.1)
|
5
|
+
minitest (5.25.5)
|
6
|
+
nokogiri (1.18.8-aarch64-linux-gnu)
|
7
|
+
racc (~> 1.4)
|
8
|
+
nokogiri (1.18.8-aarch64-linux-musl)
|
9
|
+
racc (~> 1.4)
|
10
|
+
nokogiri (1.18.8-arm-linux-gnu)
|
11
|
+
racc (~> 1.4)
|
12
|
+
nokogiri (1.18.8-arm-linux-musl)
|
13
|
+
racc (~> 1.4)
|
14
|
+
nokogiri (1.18.8-arm64-darwin)
|
15
|
+
racc (~> 1.4)
|
16
|
+
nokogiri (1.18.8-x86_64-darwin)
|
17
|
+
racc (~> 1.4)
|
18
|
+
nokogiri (1.18.8-x86_64-linux-gnu)
|
19
|
+
racc (~> 1.4)
|
20
|
+
nokogiri (1.18.8-x86_64-linux-musl)
|
21
|
+
racc (~> 1.4)
|
22
|
+
racc (1.8.1)
|
23
|
+
rake (13.2.1)
|
24
|
+
rubyzip (2.4.1)
|
25
|
+
simplecov (0.22.0)
|
26
|
+
docile (~> 1.1)
|
27
|
+
simplecov-html (~> 0.11)
|
28
|
+
simplecov_json_formatter (~> 0.1)
|
29
|
+
simplecov-html (0.13.1)
|
30
|
+
simplecov_json_formatter (0.1.4)
|
31
|
+
|
32
|
+
PLATFORMS
|
33
|
+
aarch64-linux-gnu
|
34
|
+
aarch64-linux-musl
|
35
|
+
arm-linux-gnu
|
36
|
+
arm-linux-musl
|
37
|
+
arm64-darwin
|
38
|
+
x86_64-darwin
|
39
|
+
x86_64-linux-gnu
|
40
|
+
x86_64-linux-musl
|
41
|
+
|
42
|
+
DEPENDENCIES
|
43
|
+
minitest (~> 5.25)
|
44
|
+
nokogiri (~> 1.18)
|
45
|
+
rake (~> 13.2)
|
46
|
+
rubyzip (~> 2.4)
|
47
|
+
simplecov
|
48
|
+
|
49
|
+
RUBY VERSION
|
50
|
+
ruby 3.4.3p32
|
51
|
+
|
52
|
+
BUNDLED WITH
|
53
|
+
2.6.7
|
data/README.md
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
# EPUB Tools
|
2
|
+
|
3
|
+
**TL;DR:** A Ruby gem and CLI for working with EPUB files: extract, split, initialize, add chapters, pack, and unpack EPUB books.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
Install the gem via RubyGems:
|
7
|
+
```bash
|
8
|
+
gem install epub_tools
|
9
|
+
```
|
10
|
+
|
11
|
+
Or build and install locally:
|
12
|
+
```bash
|
13
|
+
bundle install
|
14
|
+
gem build epub_tools.gemspec
|
15
|
+
gem install ./epub_tools-*.gem
|
16
|
+
```
|
17
|
+
|
18
|
+
## CLI Usage
|
19
|
+
After installation, use the `epub-tools` executable:
|
20
|
+
|
21
|
+
```bash
|
22
|
+
Usage: epub-tools COMMAND [options]
|
23
|
+
```
|
24
|
+
|
25
|
+
Commands:
|
26
|
+
- `init` Initialize a new EPUB directory structure
|
27
|
+
- `extract` Extract XHTML files from EPUB archives
|
28
|
+
- `split` Split an XHTML file into separate chapter files
|
29
|
+
- `add` Add chapter XHTML files into an existing EPUB
|
30
|
+
- `pack` Package an EPUB directory into a `.epub` file
|
31
|
+
- `unpack` Unpack a `.epub` file into a directory
|
32
|
+
- `compile` Takes EPUBs in a dir and splits, cleans, and compiles into a single EPUB
|
33
|
+
|
34
|
+
Run `epub-tools COMMAND --help` for details on options.
|
35
|
+
|
36
|
+
### Example
|
37
|
+
```bash
|
38
|
+
# Extract XHTMLs
|
39
|
+
epub-tools extract -s source_epubs -t xhtml_output
|
40
|
+
|
41
|
+
# Split chapters
|
42
|
+
epub-tools split -i xhtml_output/chapter1.xhtml -t "My Book" -o chapters
|
43
|
+
|
44
|
+
# Initialize EPUB
|
45
|
+
epub-tools init -t "My Book" -a "Author Name" -o epub_dir -c cover.jpg
|
46
|
+
|
47
|
+
# Add chapters to EPUB
|
48
|
+
epub-tools add -c chapters -e epub_dir/OEBPS
|
49
|
+
|
50
|
+
# Package EPUB (Ruby)
|
51
|
+
epub-tools pack -i epub_dir -o MyBook.epub
|
52
|
+
|
53
|
+
# Unpack EPUB
|
54
|
+
epub-tools unpack -i MyBook.epub -o unpacked_dir
|
55
|
+
|
56
|
+
# Full compile workflow: extract, split, initialize, add, and pack into one EPUB
|
57
|
+
epub-tools compile -t "My Book" -a "Author Name" -s source_epubs -c cover.jpg -o MyBook.epub
|
58
|
+
```
|
59
|
+
|
60
|
+
(Legacy script references removed; see CLI Usage above)
|
61
|
+
|
62
|
+
## Library Usage
|
63
|
+
Use the library directly in Ruby:
|
64
|
+
```ruby
|
65
|
+
require 'epub_tools'
|
66
|
+
|
67
|
+
# Extract XHTML
|
68
|
+
EpubTools::XHTMLExtractor.new(
|
69
|
+
source_dir: 'source_epubs',
|
70
|
+
target_dir: 'xhtml_output',
|
71
|
+
verbose: true
|
72
|
+
).extract_all
|
73
|
+
|
74
|
+
# Split chapters
|
75
|
+
EpubTools::SplitChapters.new(
|
76
|
+
'xhtml_output/chapter1.xhtml',
|
77
|
+
'My Book',
|
78
|
+
'chapters',
|
79
|
+
'chapter'
|
80
|
+
).run
|
81
|
+
|
82
|
+
# Initialize EPUB
|
83
|
+
EpubTools::EpubInitializer.new(
|
84
|
+
'My Book',
|
85
|
+
'Author Name',
|
86
|
+
'epub_dir',
|
87
|
+
'cover.jpg'
|
88
|
+
).run
|
89
|
+
|
90
|
+
# Add chapters
|
91
|
+
EpubTools::AddChaptersToEpub.new('chapters', 'epub_dir/OEBPS').run
|
92
|
+
|
93
|
+
# Pack EPUB
|
94
|
+
EpubTools::PackEbook.new('epub_dir', 'MyBook.epub').run
|
95
|
+
|
96
|
+
# Unpack EPUB
|
97
|
+
EpubTools::UnpackEbook.new('MyBook.epub', 'unpacked_dir').run
|
98
|
+
```
|
99
|
+
## Development & Testing
|
100
|
+
Clone the repo and install dependencies:
|
101
|
+
```bash
|
102
|
+
git clone <repo-url>
|
103
|
+
cd epub_tools
|
104
|
+
bundle install
|
105
|
+
```
|
106
|
+
|
107
|
+
Run tests:
|
108
|
+
```bash
|
109
|
+
bundle exec rake test
|
110
|
+
```
|
111
|
+
|
112
|
+
Enable coverage reporting:
|
113
|
+
```bash
|
114
|
+
COVERAGE=true bundle exec rake test
|
115
|
+
```
|
116
|
+
|
117
|
+
## Contributing
|
118
|
+
Pull requests welcome! Please open an issue for major changes.
|
data/Rakefile
ADDED
data/bin/epub-tools
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require_relative '../lib/epub_tools'
|
3
|
+
|
4
|
+
prog = File.basename($PROGRAM_NAME)
|
5
|
+
script_dir = File.expand_path(File.join(__dir__, '..'))
|
6
|
+
commands = %w[add extract init split pack unpack compile]
|
7
|
+
|
8
|
+
if ARGV.empty? || !commands.include?(ARGV[0])
|
9
|
+
puts <<~USAGE
|
10
|
+
Usage: #{prog} COMMAND [options]
|
11
|
+
Commands:
|
12
|
+
init Initialize a bare-bones EPUB
|
13
|
+
extract Extract XHTML files from EPUBs
|
14
|
+
split Split XHTML into separate XHTMLs per chapter
|
15
|
+
add Add chapter XHTML files into an EPUB
|
16
|
+
pack Package an EPUB directory into a .epub file
|
17
|
+
unpack Unpack an EPUB file into a directory
|
18
|
+
compile Takes EPUBs in a dir and splits, cleans, and compiles into a single EPUB.
|
19
|
+
USAGE
|
20
|
+
exit 1
|
21
|
+
end
|
22
|
+
|
23
|
+
cmd = ARGV.shift
|
24
|
+
|
25
|
+
case cmd
|
26
|
+
when 'add'
|
27
|
+
options = {}
|
28
|
+
EpubTools::CLIHelper.parse(options, [:chapters_dir, :epub_oebps_dir]) do |opts, o|
|
29
|
+
opts.banner = "Usage: #{prog} add [options]"
|
30
|
+
opts.on('-c DIR', '--chapters-dir DIR', 'Chapters directory (required)') { |v| o[:chapters_dir] = v }
|
31
|
+
opts.on('-e DIR', '--epub-oebps-dir DIR', 'EPUB OEBPS directory (required)') { |v| o[:epub_oebps_dir] = v }
|
32
|
+
end
|
33
|
+
|
34
|
+
EpubTools::AddChaptersToEpub.new(options[:chapters_dir], options[:epub_oebps_dir]).run
|
35
|
+
|
36
|
+
when 'extract'
|
37
|
+
options = { verbose: true }
|
38
|
+
EpubTools::CLIHelper.parse(options, [:source_dir, :target_dir]) do |opts, o|
|
39
|
+
opts.banner = "Usage: #{prog} extract [options]"
|
40
|
+
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') { |v| o[:source_dir] = v }
|
41
|
+
opts.on('-t DIR', '--target-dir DIR', 'Directory where the XHTML files will be extracted to (required)') { |v| o[:target_dir] = v }
|
42
|
+
opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
|
43
|
+
end
|
44
|
+
EpubTools::XHTMLExtractor.new(
|
45
|
+
source_dir: options[:source_dir],
|
46
|
+
target_dir: options[:target_dir],
|
47
|
+
verbose: options[:verbose]
|
48
|
+
).extract_all
|
49
|
+
|
50
|
+
when 'split'
|
51
|
+
options = { output_dir: './chapters', prefix: 'chapter', verbose: true }
|
52
|
+
EpubTools::CLIHelper.parse(options, [:input_file, :book_title]) do |opts, o|
|
53
|
+
opts.banner = "Usage: #{prog} split [options]"
|
54
|
+
opts.on('-i FILE', '--input FILE', 'Source XHTML file (required)') { |v| options[:input_file] = v }
|
55
|
+
opts.on('-t TITLE', '--title TITLE', 'Book title for HTML <title> tags (required)') { |v| options[:book_title] = v }
|
56
|
+
opts.on('-o DIR', '--output-dir DIR', "Output directory for chapter files (default: #{options[:output_dir]})") { |v| options[:output_dir] = v }
|
57
|
+
opts.on('-p PREFIX', '--prefix PREFIX', "Filename prefix for chapters (default: #{options[:prefix]})") { |v| options[:prefix] = v }
|
58
|
+
opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
|
59
|
+
end
|
60
|
+
EpubTools::SplitChapters.new(options[:input_file], options[:book_title], options[:output_dir], options[:prefix], options[:verbose]).run
|
61
|
+
|
62
|
+
when 'init'
|
63
|
+
options = {}
|
64
|
+
EpubTools::CLIHelper.parse(options, [:title, :author, :destination]) do |opts, o|
|
65
|
+
opts.banner = "Usage: #{prog} init [options]"
|
66
|
+
opts.on('-t TITLE', '--title TITLE', 'Book title (required)') { |v| o[:title] = v }
|
67
|
+
opts.on('-a AUTHOR', '--author AUTHOR', 'Author name (required)') { |v| o[:author] = v }
|
68
|
+
opts.on('-o DIR', '--output-dir DIR', 'Destination EPUB directory (required)') { |v| o[:destination] = v }
|
69
|
+
opts.on('-c PATH', '--cover PATH', 'Cover image file path (optional)') { |v| o[:cover_image] = v }
|
70
|
+
end
|
71
|
+
|
72
|
+
EpubTools::EpubInitializer.new(options[:title], options[:author], options[:destination], options[:cover_image]).run
|
73
|
+
|
74
|
+
when 'pack'
|
75
|
+
options = {verbose: true}
|
76
|
+
EpubTools::CLIHelper.parse(options, [:input_dir, :output_file]) do |opts, o|
|
77
|
+
opts.banner = "Usage: #{prog} pack [options]"
|
78
|
+
opts.on('-i DIR', '--input-dir DIR', 'EPUB directory to package (required)') { |v| o[:input_dir] = v }
|
79
|
+
opts.on('-o FILE', '--output-file FILE', 'Output EPUB file path (required)') { |v| o[:output_file] = v }
|
80
|
+
opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
|
81
|
+
end
|
82
|
+
|
83
|
+
EpubTools::PackEbook.new(options[:input_dir], options[:output_file], verbose: options[:verbose]).run
|
84
|
+
|
85
|
+
when 'unpack'
|
86
|
+
options = {verbose: true}
|
87
|
+
EpubTools::CLIHelper.parse(options, [:epub_file]) do |opts, o|
|
88
|
+
opts.banner = "Usage: #{prog} unpack [options]"
|
89
|
+
opts.on('-i FILE', '--input-file FILE', 'EPUB file to unpack (required)') { |v| o[:epub_file] = v }
|
90
|
+
opts.on('-o DIR', '--output-dir DIR', 'Output directory to extract into (default: basename of epub)') { |v| o[:output_dir] = v }
|
91
|
+
opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
|
92
|
+
end
|
93
|
+
EpubTools::UnpackEbook.new(options[:epub_file], verbose: options[:verbose]).run
|
94
|
+
|
95
|
+
when 'compile'
|
96
|
+
options = {verbose: true}
|
97
|
+
EpubTools::CLIHelper.parse(options, %i(title author source_dir)) do |opts, o|
|
98
|
+
opts.banner = "Usage: #{prog} compile [options]"
|
99
|
+
opts.on('-t TITLE', '--title TITLE', 'Book title (required)') { |v| o[:title] = v }
|
100
|
+
opts.on('-a AUTHOR', '--author AUTHOR', 'Author name (required)') { |v| o[:author] = v }
|
101
|
+
opts.on('-s DIR', '--source-dir DIR', 'Directory with EPUBs to extract XHTMLs from (required)') { |v| o[:source_dir] = v }
|
102
|
+
opts.on('-o FILE', '--output FILE', 'EPUB to create (default: book title in source dir)') { |v| o[:output_file] = v }
|
103
|
+
opts.on('-c PATH', '--cover PATH', 'Cover image file path (optional)') { |v| o[:cover_image] = v }
|
104
|
+
opts.on('-q', '--quiet', 'Run quietly (default: verbose)') { |v| o[:verbose] = !v }
|
105
|
+
end
|
106
|
+
EpubTools::CompileBook.new(*options).run
|
107
|
+
end
|
data/epub_tools.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
require_relative 'lib/epub_tools/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = 'epub_tools'
|
5
|
+
spec.version = EpubTools::VERSION
|
6
|
+
spec.summary = 'Tools to extract, split, and compile EPUB books'
|
7
|
+
spec.authors = ['Jaime Rodas']
|
8
|
+
spec.email = ['rodas@hey.com']
|
9
|
+
spec.license = 'MIT'
|
10
|
+
spec.files = `git ls-files`.split("\n")
|
11
|
+
spec.require_paths = ['lib']
|
12
|
+
spec.executables = ['epub-tools']
|
13
|
+
spec.required_ruby_version = ">= 3.0"
|
14
|
+
|
15
|
+
spec.add_dependency 'nokogiri', '~> 1.18'
|
16
|
+
spec.add_dependency 'rubyzip', '~> 2.4'
|
17
|
+
spec.add_dependency 'rake', '~> 13.2'
|
18
|
+
|
19
|
+
spec.add_development_dependency 'minitest', '~> 5.25'
|
20
|
+
spec.add_development_dependency 'simplecov', '~> 0'
|
21
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'nokogiri'
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
module EpubTools
|
6
|
+
class AddChaptersToEpub
|
7
|
+
def initialize(chapters_dir = './chapters', epub_dir = './epub/OEBPS', verbose = false)
|
8
|
+
@chapters_dir = chapters_dir
|
9
|
+
@epub_dir = epub_dir
|
10
|
+
@opf_file = File.join(@epub_dir, 'package.opf')
|
11
|
+
@nav_file = File.join(@epub_dir, 'nav.xhtml')
|
12
|
+
@verbose = verbose
|
13
|
+
end
|
14
|
+
|
15
|
+
def run
|
16
|
+
moved_files = move_chapters
|
17
|
+
update_package_opf(moved_files)
|
18
|
+
update_nav_xhtml(moved_files)
|
19
|
+
@verbose ? moved_files.each {|f| puts "Moved: #{f}"} : moved_files
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def move_chapters
|
25
|
+
# Sort by chapter number (numeric)
|
26
|
+
chapter_files = Dir.glob(File.join(@chapters_dir, '*.xhtml')).sort_by do |path|
|
27
|
+
# extract first integer from filename (e.g. chapter_10.xhtml -> 10)
|
28
|
+
File.basename(path)[/\d+/].to_i
|
29
|
+
end
|
30
|
+
chapter_files.each do |file|
|
31
|
+
FileUtils.mv(file, @epub_dir)
|
32
|
+
end
|
33
|
+
chapter_files.map { |f| File.basename(f) }
|
34
|
+
end
|
35
|
+
|
36
|
+
def chapter_id(filename)
|
37
|
+
match = filename.match(/chapter_(\d+)\.xhtml/)
|
38
|
+
match ? "chap#{match[1]}" : File.basename(filename, '.xhtml')
|
39
|
+
end
|
40
|
+
|
41
|
+
def update_package_opf(filenames)
|
42
|
+
doc = Nokogiri::XML(File.read(@opf_file)) { |config| config.default_xml.noblanks }
|
43
|
+
manifest = doc.at_xpath('//xmlns:manifest')
|
44
|
+
spine = doc.at_xpath('//xmlns:spine')
|
45
|
+
|
46
|
+
filenames.each do |filename|
|
47
|
+
id = chapter_id(filename)
|
48
|
+
# Add <item> to the manifest if missing
|
49
|
+
unless doc.at_xpath("//xmlns:item[@href='#{filename}']")
|
50
|
+
item = Nokogiri::XML::Node.new('item', doc)
|
51
|
+
item['id'] = id
|
52
|
+
item['href'] = filename
|
53
|
+
item['media-type'] = 'application/xhtml+xml'
|
54
|
+
manifest.add_child(item)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Add <itemref> to the spine if missing
|
58
|
+
unless doc.at_xpath("//xmlns:itemref[@idref='#{id}']")
|
59
|
+
itemref = Nokogiri::XML::Node.new('itemref', doc)
|
60
|
+
itemref['idref'] = id
|
61
|
+
spine.add_child(itemref)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
File.write(@opf_file, doc.to_xml(indent: 2))
|
66
|
+
end
|
67
|
+
|
68
|
+
def update_nav_xhtml(filenames)
|
69
|
+
doc = Nokogiri::XML(File.read(@nav_file)) { |config| config.default_xml.noblanks }
|
70
|
+
nav = doc.at_xpath('//xmlns:nav[@epub:type="toc"]/xmlns:ol')
|
71
|
+
|
72
|
+
filenames.each do |filename|
|
73
|
+
# Create a new <li><a href="...">Label</a></li> element
|
74
|
+
label = File.basename(filename, '.xhtml').gsub('_', ' ').capitalize
|
75
|
+
label = "Prologue" if label == "Chapter 0"
|
76
|
+
li = Nokogiri::XML::Node.new('li', doc)
|
77
|
+
a = Nokogiri::XML::Node.new('a', doc)
|
78
|
+
a['href'] = filename
|
79
|
+
a.content = label
|
80
|
+
li.add_child(a)
|
81
|
+
nav.add_child(li)
|
82
|
+
end
|
83
|
+
|
84
|
+
File.write(@nav_file, doc.to_xml(indent: 2))
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module EpubTools
|
4
|
+
# A simple helper to DRY CLI OptionParser usage across commands
|
5
|
+
class CLIHelper
|
6
|
+
# Parses ARGV into options hash, enforces required keys, and displays help/errors.
|
7
|
+
# options: hash of defaults; required_keys: array of symbols required
|
8
|
+
def self.parse(options = {}, required_keys = [], &block)
|
9
|
+
parser = OptionParser.new do |opts|
|
10
|
+
block.call(opts, options)
|
11
|
+
opts.on('-h', '--help', 'Prints this help') { puts opts; exit }
|
12
|
+
end
|
13
|
+
begin
|
14
|
+
parser.parse!
|
15
|
+
unless required_keys.empty?
|
16
|
+
missing = required_keys.select { |k| options[k].nil? }
|
17
|
+
unless missing.empty?
|
18
|
+
STDERR.puts "Missing required options: #{missing.map { |k| "--#{k.to_s.gsub('_','-')}" }.join(', ')}"
|
19
|
+
STDERR.puts parser
|
20
|
+
exit 1
|
21
|
+
end
|
22
|
+
end
|
23
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
24
|
+
STDERR.puts e.message
|
25
|
+
STDERR.puts parser
|
26
|
+
exit 1
|
27
|
+
end
|
28
|
+
options
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'fileutils'
|
3
|
+
$LOAD_PATH.unshift File.expand_path('../../', __FILE__)
|
4
|
+
require 'epub_tools'
|
5
|
+
|
6
|
+
module EpubTools
|
7
|
+
# Orchestrates extraction, splitting, validation, and packaging of book EPUBs
|
8
|
+
class CompileBook
|
9
|
+
attr_reader :title, :author, :source_dir, :cover_image, :output_file, :build_dir, :verbose
|
10
|
+
|
11
|
+
# title: String, author: String, source_dir: path to input epubs
|
12
|
+
# cover_image: optional path to cover image, output_file: filename for final epub
|
13
|
+
# build_dir: optional working directory for intermediate files
|
14
|
+
def initialize(title:, author:, source_dir:, cover_image: nil, output_file: nil, build_dir: nil, verbose: false)
|
15
|
+
@title = title
|
16
|
+
@author = author
|
17
|
+
@source_dir = source_dir
|
18
|
+
@cover_image = cover_image
|
19
|
+
@output_file = output_file || default_output_file
|
20
|
+
@build_dir = build_dir || File.join(Dir.pwd, '.epub_tools_build')
|
21
|
+
@verbose = verbose
|
22
|
+
end
|
23
|
+
|
24
|
+
# Run the full compile workflow
|
25
|
+
def run
|
26
|
+
clean_build_dir
|
27
|
+
prepare_dirs
|
28
|
+
extract_xhtmls
|
29
|
+
split_xhtmls
|
30
|
+
validate_sequence
|
31
|
+
initialize_epub
|
32
|
+
add_chapters
|
33
|
+
pack_epub
|
34
|
+
log "Done. Output EPUB: #{File.expand_path(output_file)}"
|
35
|
+
clean_build_dir
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def log(message)
|
41
|
+
puts message if verbose
|
42
|
+
end
|
43
|
+
|
44
|
+
def default_output_file
|
45
|
+
"#{title.gsub(' ', '_')}.epub"
|
46
|
+
end
|
47
|
+
|
48
|
+
def clean_build_dir
|
49
|
+
log "Cleaning build directory #{build_dir}..."
|
50
|
+
FileUtils.rm_rf(build_dir)
|
51
|
+
end
|
52
|
+
|
53
|
+
def prepare_dirs
|
54
|
+
log "Preparing build directories..."
|
55
|
+
FileUtils.mkdir_p(xhtml_dir)
|
56
|
+
FileUtils.mkdir_p(chapters_dir)
|
57
|
+
end
|
58
|
+
|
59
|
+
def xhtml_dir
|
60
|
+
@xhtml_dir ||= File.join(build_dir, 'xhtml')
|
61
|
+
end
|
62
|
+
|
63
|
+
def chapters_dir
|
64
|
+
@chapters_dir ||= File.join(build_dir, 'chapters')
|
65
|
+
end
|
66
|
+
|
67
|
+
def epub_dir
|
68
|
+
@epub_dir ||= File.join(build_dir, 'epub')
|
69
|
+
end
|
70
|
+
|
71
|
+
def extract_xhtmls
|
72
|
+
log "Extracting XHTML files from epubs in '#{source_dir}'..."
|
73
|
+
XHTMLExtractor.new(source_dir: source_dir, target_dir: xhtml_dir, verbose: verbose).extract_all
|
74
|
+
end
|
75
|
+
|
76
|
+
def split_xhtmls
|
77
|
+
log "Splitting XHTML files into chapters..."
|
78
|
+
Dir.glob(File.join(xhtml_dir, '*.xhtml')).each do |xhtml_file|
|
79
|
+
base = File.basename(xhtml_file, '.xhtml')
|
80
|
+
log "Splitting '#{base}'..."
|
81
|
+
SplitChapters.new(xhtml_file, title, chapters_dir, 'chapter', verbose).run
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def validate_sequence
|
86
|
+
log "Validating chapter sequence..."
|
87
|
+
nums = Dir.glob(File.join(chapters_dir, '*.xhtml')).map do |file|
|
88
|
+
if (m = File.basename(file, '.xhtml').match(/_(\d+)\z/))
|
89
|
+
m[1].to_i
|
90
|
+
end
|
91
|
+
end.compact
|
92
|
+
raise "No chapter files found in #{chapters_dir}" if nums.empty?
|
93
|
+
sorted = nums.sort.uniq
|
94
|
+
missing = (sorted.first..sorted.last).to_a - sorted
|
95
|
+
if missing.any?
|
96
|
+
raise "Missing chapter numbers: #{missing.join(' ')}"
|
97
|
+
else
|
98
|
+
log "Chapter sequence is complete: #{sorted.first} to #{sorted.last}."
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def initialize_epub
|
103
|
+
log "Initializing new EPUB..."
|
104
|
+
if cover_image
|
105
|
+
EpubInitializer.new(title, author, epub_dir, cover_image).run
|
106
|
+
else
|
107
|
+
EpubInitializer.new(title, author, epub_dir).run
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def add_chapters
|
112
|
+
log "Adding chapters to EPUB..."
|
113
|
+
AddChaptersToEpub.new(chapters_dir, File.join(epub_dir, 'OEBPS'), verbose).run
|
114
|
+
end
|
115
|
+
|
116
|
+
def pack_epub
|
117
|
+
log "Building final EPUB '#{output_file}'..."
|
118
|
+
PackEbook.new(epub_dir, output_file, verbose: verbose).run
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|