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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 79e280c3b4dd2f362ee8c013e98f6ac44a9ea5c18535bd0b6dba67c1a5a49f52
|
|
4
|
+
data.tar.gz: 1c6a8eac67cced477f7312eb5ec50091048c5bf5932264b5de9f1396c8bd6879
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe396cf56d97ee602ad326f50614f21edc31b0af9ef0ed82656826f567e5dc5ad7df92620e1ba6b79203dbabac75ee03cff2c2ec597cb8763b35ac601ee72a79
|
|
7
|
+
data.tar.gz: 6a293f1075e4ff4b2ee26a2e11c2aedc5ec39b56d638dacb0c8feae916eb405024be0006b7fa4310afa333b871429130c6ec2265f236b9b7e998591103066ba4
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -12,14 +12,9 @@ permissions:
|
|
|
12
12
|
jobs:
|
|
13
13
|
test:
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
|
-
strategy:
|
|
16
|
-
matrix:
|
|
17
|
-
ruby: ['3.2', '3.3', '3.4']
|
|
18
15
|
steps:
|
|
19
|
-
- uses: actions/checkout@
|
|
20
|
-
- uses: ruby/setup-ruby@v1
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
- uses: ruby/setup-ruby@v1 # Ruby version from .tool-versions
|
|
21
18
|
with:
|
|
22
|
-
|
|
23
|
-
- run: gem install bundler
|
|
24
|
-
- run: bundle install --jobs 4 --retry 3
|
|
19
|
+
bundler-cache: true
|
|
25
20
|
- run: bundle exec rake test
|
data/.gitignore
CHANGED
data/.tool-versions
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
ruby
|
|
1
|
+
ruby 4.0.9
|
data/CLAUDE.md
CHANGED
|
@@ -59,40 +59,38 @@ gem install ./epub_tools-*.gem
|
|
|
59
59
|
### Core Components
|
|
60
60
|
|
|
61
61
|
- **Main Module** (`lib/epub_tools.rb`): Entry point that requires all components
|
|
62
|
-
- **CLI
|
|
63
|
-
- `Runner`: Main CLI runner that handles command dispatch
|
|
64
|
-
- `CommandRegistry`: Manages available commands and their configurations
|
|
65
|
-
- `OptionBuilder`: Builds command-line option parsers
|
|
66
|
-
- `CommandOptionsConfigurator`: Handles command-specific option configuration
|
|
62
|
+
- **CLI** (`lib/epub_tools/cli.rb`): `CLI::COMMANDS` maps each command to its class, summary and flags
|
|
67
63
|
- **Core Classes**: Individual operation classes for EPUB manipulation
|
|
68
64
|
- `XHTMLExtractor`: Extracts XHTML files from EPUB archives
|
|
69
|
-
- `
|
|
70
|
-
- `
|
|
65
|
+
- `PDFConverter`: Converts chapter PDFs (named `12a Title.pdf`) into chapter XHTMLs via poppler's `pdftohtml -xml`
|
|
66
|
+
- `SplitChapters`: Splits XHTML files into separate chapters at Chapter N / Chapter N (continued) / Prologue markers
|
|
67
|
+
- `EpubInitializer`: Creates new EPUB directory structure
|
|
71
68
|
- `AddChapters`: Adds chapter files to existing EPUB
|
|
72
69
|
- `PackEbook`: Packages EPUB directories into .epub files
|
|
73
70
|
- `UnpackEbook`: Unpacks .epub files into directories
|
|
74
|
-
- `
|
|
75
|
-
- **
|
|
76
|
-
- `
|
|
71
|
+
- `SetCover`: Adds or replaces a cover, in a packed EPUB (`cover` command) or an unpacked one (used by `EpubInitializer`)
|
|
72
|
+
- **Workflow Classes**: Orchestrators built on a shared base class
|
|
73
|
+
- `BookBuilder`: Base class with template method pattern (extract → split → convert PDFs → validate → add → pack)
|
|
74
|
+
- `CompileBook`: Creates a new EPUB from source EPUBs (inherits BookBuilder)
|
|
75
|
+
- `AppendBook`: Appends chapters from source EPUBs to an existing EPUB (inherits BookBuilder)
|
|
76
|
+
- **Supporting Classes**
|
|
77
77
|
- `ChapterValidator`: Validates chapter sequence completeness
|
|
78
|
-
- `
|
|
79
|
-
- `
|
|
80
|
-
- `
|
|
81
|
-
- `EpubFileWriter`: Handles EPUB file writing operations
|
|
78
|
+
- `PDFLayout`: Rebuilds paragraphs, italics, scene breaks and lists from `pdftohtml -xml` line positions
|
|
79
|
+
- `ChapterOrder`: Reading order of chapter files (1, 1_5, 1a, 1b, 2); inserts added chapters in place
|
|
80
|
+
- `StyleFinder` / `XHTMLCleaner`: Find Google Docs' bold/italic classes and clean split chapters with them
|
|
82
81
|
|
|
83
82
|
### CLI Architecture
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
3. The `CommandOptionsConfigurator` handles command-specific option setup
|
|
84
|
+
1. Each command is an entry in `CLI::COMMANDS`: its class, usage summary, and flags as `[short, long, description, option key]`
|
|
85
|
+
2. Flags whose description ends in `(required)` are enforced; commands get `-q/--quiet` unless marked `verbose: false`
|
|
86
|
+
3. `CLI.run` parses the flags and calls `command_class.new(options).run`
|
|
89
87
|
4. Each command class implements a `run` method and uses the `Loggable` mixin for verbose output
|
|
90
88
|
|
|
91
89
|
### Dependencies
|
|
92
90
|
|
|
93
91
|
- **nokogiri**: XML/HTML parsing for EPUB content
|
|
94
92
|
- **rubyzip**: ZIP file manipulation for EPUB packaging
|
|
95
|
-
- **rake**: Build tasks and testing
|
|
93
|
+
- **rake**: Build tasks and testing (development only)
|
|
96
94
|
- **minitest**: Testing framework
|
|
97
95
|
- **rubocop**: Code linting with custom configuration
|
|
98
96
|
- **simplecov**: Test coverage reporting
|
|
@@ -111,14 +109,12 @@ The CLI uses a registry-based system where:
|
|
|
111
109
|
Tests use Minitest with:
|
|
112
110
|
- `test_helper.rb` sets up SimpleCov coverage
|
|
113
111
|
- Tests in `test/` directory follow `*_test.rb` naming
|
|
114
|
-
- CLI tests verify
|
|
112
|
+
- CLI tests verify option parsing and dispatch; `cli_commands_test.rb` runs the real executable
|
|
115
113
|
- Individual component tests verify core functionality
|
|
116
114
|
|
|
117
115
|
### Code Quality
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
-
|
|
121
|
-
- **Open/Closed**: Extensible design through composition and dependency injection
|
|
122
|
-
- **Dependency Inversion**: Configuration objects and factory patterns
|
|
117
|
+
- One class per operation, each with an options hash and a `run` method
|
|
118
|
+
- Prefer plain methods, stdlib and the existing dependencies over new helper classes
|
|
123
119
|
|
|
124
120
|
RuboCop configuration excludes test files from metrics cops while maintaining strict standards for production code.
|
data/Gemfile
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
ruby '>= 3.2'
|
|
4
|
-
|
|
5
3
|
source 'https://rubygems.org'
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
gem 'rake', '~> 13.2'
|
|
9
|
-
gem 'rubyzip', '~> 3.2'
|
|
5
|
+
gemspec
|
|
10
6
|
|
|
11
7
|
group :test, :development do
|
|
12
8
|
gem 'minitest', '~> 6.0'
|
|
9
|
+
gem 'rake', '~> 13.2'
|
|
13
10
|
gem 'rubocop', '~> 1.75', require: false
|
|
14
|
-
gem 'rubocop-minitest', '~> 0.
|
|
11
|
+
gem 'rubocop-minitest', '~> 0.41.0', require: false
|
|
15
12
|
gem 'rubocop-rake', '~> 0.7.1', require: false
|
|
16
13
|
gem 'simplecov', require: false
|
|
17
14
|
end
|
|
18
15
|
|
|
19
16
|
group :doc do
|
|
20
|
-
gem 'rdoc', '~>
|
|
17
|
+
gem 'rdoc', '~> 8.1'
|
|
21
18
|
gem 'webrick', '~> 1.9'
|
|
22
|
-
gem 'yard', '~> 0.9.
|
|
19
|
+
gem 'yard', '~> 0.9.44'
|
|
23
20
|
end
|
data/Gemfile.lock
CHANGED
|
@@ -1,85 +1,95 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
epub_tools (0.9.0)
|
|
5
|
+
nokogiri (~> 1.19)
|
|
6
|
+
rubyzip (~> 3.7)
|
|
7
|
+
|
|
1
8
|
GEM
|
|
2
9
|
remote: https://rubygems.org/
|
|
3
10
|
specs:
|
|
4
11
|
ast (2.4.3)
|
|
5
|
-
date (3.5.1)
|
|
6
|
-
docile (1.4.1)
|
|
7
12
|
drb (2.2.3)
|
|
8
|
-
erb (6.0.
|
|
9
|
-
json (
|
|
10
|
-
language_server-protocol (3.17.0.
|
|
13
|
+
erb (6.0.7)
|
|
14
|
+
json (3.1.0)
|
|
15
|
+
language_server-protocol (3.17.0.6)
|
|
11
16
|
lint_roller (1.1.0)
|
|
12
|
-
|
|
17
|
+
logger (1.7.0)
|
|
18
|
+
minitest (6.1.0)
|
|
13
19
|
drb (~> 2.0)
|
|
14
|
-
prism (
|
|
15
|
-
nokogiri (1.19.
|
|
20
|
+
prism (>= 1.5, < 3)
|
|
21
|
+
nokogiri (1.19.7-aarch64-linux-gnu)
|
|
16
22
|
racc (~> 1.4)
|
|
17
|
-
nokogiri (1.19.
|
|
23
|
+
nokogiri (1.19.7-aarch64-linux-musl)
|
|
18
24
|
racc (~> 1.4)
|
|
19
|
-
nokogiri (1.19.
|
|
25
|
+
nokogiri (1.19.7-arm-linux-gnu)
|
|
20
26
|
racc (~> 1.4)
|
|
21
|
-
nokogiri (1.19.
|
|
27
|
+
nokogiri (1.19.7-arm-linux-musl)
|
|
22
28
|
racc (~> 1.4)
|
|
23
|
-
nokogiri (1.19.
|
|
29
|
+
nokogiri (1.19.7-arm64-darwin)
|
|
24
30
|
racc (~> 1.4)
|
|
25
|
-
nokogiri (1.19.
|
|
31
|
+
nokogiri (1.19.7-x86_64-darwin)
|
|
26
32
|
racc (~> 1.4)
|
|
27
|
-
nokogiri (1.19.
|
|
33
|
+
nokogiri (1.19.7-x86_64-linux-gnu)
|
|
28
34
|
racc (~> 1.4)
|
|
29
|
-
nokogiri (1.19.
|
|
35
|
+
nokogiri (1.19.7-x86_64-linux-musl)
|
|
30
36
|
racc (~> 1.4)
|
|
31
|
-
parallel (
|
|
32
|
-
parser (3.3.
|
|
37
|
+
parallel (2.3.0)
|
|
38
|
+
parser (3.3.13.0)
|
|
33
39
|
ast (~> 2.4.1)
|
|
34
40
|
racc
|
|
35
|
-
prism (1.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
prism (2.1.0)
|
|
42
|
+
prism (2.1.0-aarch64-linux-gnu)
|
|
43
|
+
prism (2.1.0-aarch64-linux-musl)
|
|
44
|
+
prism (2.1.0-arm-linux-gnu)
|
|
45
|
+
prism (2.1.0-arm-linux-musl)
|
|
46
|
+
prism (2.1.0-arm64-darwin)
|
|
47
|
+
prism (2.1.0-x86_64-darwin)
|
|
48
|
+
prism (2.1.0-x86_64-linux-gnu)
|
|
49
|
+
prism (2.1.0-x86_64-linux-musl)
|
|
39
50
|
racc (1.8.1)
|
|
40
51
|
rainbow (3.1.1)
|
|
41
|
-
rake (13.
|
|
42
|
-
|
|
52
|
+
rake (13.4.2)
|
|
53
|
+
rbs (4.2.0)
|
|
54
|
+
logger
|
|
55
|
+
prism (>= 1.6.0)
|
|
56
|
+
tsort
|
|
57
|
+
rdoc (8.1.0)
|
|
43
58
|
erb
|
|
44
|
-
|
|
59
|
+
prism (>= 1.6.0)
|
|
60
|
+
rbs (>= 4.0.0)
|
|
45
61
|
tsort
|
|
46
|
-
regexp_parser (2.
|
|
47
|
-
rubocop (1.
|
|
48
|
-
json (
|
|
62
|
+
regexp_parser (2.13.1)
|
|
63
|
+
rubocop (1.92.2)
|
|
64
|
+
json (>= 2.3)
|
|
49
65
|
language_server-protocol (~> 3.17.0.2)
|
|
50
66
|
lint_roller (~> 1.1.0)
|
|
51
|
-
parallel (
|
|
67
|
+
parallel (>= 1.10)
|
|
52
68
|
parser (>= 3.3.0.2)
|
|
53
69
|
rainbow (>= 2.2.2, < 4.0)
|
|
54
|
-
regexp_parser (>= 2.
|
|
70
|
+
regexp_parser (>= 2.11.3, < 3.0)
|
|
55
71
|
rubocop-ast (>= 1.49.0, < 2.0)
|
|
56
72
|
ruby-progressbar (~> 1.7)
|
|
57
73
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
58
|
-
rubocop-ast (1.
|
|
74
|
+
rubocop-ast (1.51.0)
|
|
59
75
|
parser (>= 3.3.7.2)
|
|
60
|
-
prism (
|
|
61
|
-
rubocop-minitest (0.
|
|
76
|
+
prism (>= 1.7)
|
|
77
|
+
rubocop-minitest (0.41.0)
|
|
62
78
|
lint_roller (~> 1.1)
|
|
63
|
-
rubocop (>= 1.
|
|
79
|
+
rubocop (>= 1.89.0, < 2.0)
|
|
64
80
|
rubocop-ast (>= 1.38.0, < 2.0)
|
|
65
81
|
rubocop-rake (0.7.1)
|
|
66
82
|
lint_roller (~> 1.1)
|
|
67
83
|
rubocop (>= 1.72.1)
|
|
68
84
|
ruby-progressbar (1.13.0)
|
|
69
|
-
rubyzip (3.
|
|
70
|
-
simplecov (
|
|
71
|
-
docile (~> 1.1)
|
|
72
|
-
simplecov-html (~> 0.11)
|
|
73
|
-
simplecov_json_formatter (~> 0.1)
|
|
74
|
-
simplecov-html (0.13.2)
|
|
75
|
-
simplecov_json_formatter (0.1.4)
|
|
76
|
-
stringio (3.2.0)
|
|
85
|
+
rubyzip (3.7.0)
|
|
86
|
+
simplecov (1.3.2)
|
|
77
87
|
tsort (0.2.0)
|
|
78
|
-
unicode-display_width (3.
|
|
79
|
-
unicode-emoji (~> 4.
|
|
80
|
-
unicode-emoji (4.
|
|
88
|
+
unicode-display_width (3.3.0)
|
|
89
|
+
unicode-emoji (~> 4.3)
|
|
90
|
+
unicode-emoji (4.3.0)
|
|
81
91
|
webrick (1.9.2)
|
|
82
|
-
yard (0.9.
|
|
92
|
+
yard (0.9.45)
|
|
83
93
|
|
|
84
94
|
PLATFORMS
|
|
85
95
|
aarch64-linux-gnu
|
|
@@ -92,20 +102,16 @@ PLATFORMS
|
|
|
92
102
|
x86_64-linux-musl
|
|
93
103
|
|
|
94
104
|
DEPENDENCIES
|
|
105
|
+
epub_tools!
|
|
95
106
|
minitest (~> 6.0)
|
|
96
|
-
nokogiri (~> 1.18)
|
|
97
107
|
rake (~> 13.2)
|
|
98
|
-
rdoc (~>
|
|
108
|
+
rdoc (~> 8.1)
|
|
99
109
|
rubocop (~> 1.75)
|
|
100
|
-
rubocop-minitest (~> 0.
|
|
110
|
+
rubocop-minitest (~> 0.41.0)
|
|
101
111
|
rubocop-rake (~> 0.7.1)
|
|
102
|
-
rubyzip (~> 3.2)
|
|
103
112
|
simplecov
|
|
104
113
|
webrick (~> 1.9)
|
|
105
|
-
yard (~> 0.9.
|
|
106
|
-
|
|
107
|
-
RUBY VERSION
|
|
108
|
-
ruby 3.4.5p51
|
|
114
|
+
yard (~> 0.9.44)
|
|
109
115
|
|
|
110
116
|
BUNDLED WITH
|
|
111
|
-
|
|
117
|
+
4.0.24
|
data/README.md
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jaimerodas/epub_tools/actions) [](LICENSE) [](https://badge.fury.io/rb/epub_tools)
|
|
4
4
|
|
|
5
|
-
**TL;DR:** A Ruby gem and CLI for working with EPUB files: extract, split, initialize, add chapters, pack, and
|
|
5
|
+
**TL;DR:** A Ruby gem and CLI for working with EPUB files: extract, split, initialize, add chapters, pack, unpack, compile, and append to EPUB books.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
9
|
### Requirements
|
|
10
|
-
- Ruby
|
|
10
|
+
- Ruby 4.0 or higher
|
|
11
|
+
- [poppler](https://poppler.freedesktop.org/) (`brew install poppler`), only for PDF sources
|
|
11
12
|
|
|
12
13
|
### Install from RubyGems
|
|
13
14
|
```bash
|
|
@@ -32,10 +33,13 @@ Commands:
|
|
|
32
33
|
- `init` Initialize a new EPUB directory structure
|
|
33
34
|
- `extract` Extract XHTML files from EPUB archives
|
|
34
35
|
- `split` Split an XHTML file into separate chapter files
|
|
36
|
+
- `pdf` Convert chapter PDFs into chapter XHTML files
|
|
35
37
|
- `add` Add chapter XHTML files into an existing EPUB
|
|
36
38
|
- `pack` Package an EPUB directory into a `.epub` file
|
|
37
39
|
- `unpack` Unpack a `.epub` file into a directory
|
|
38
|
-
- `
|
|
40
|
+
- `cover` Add or replace the cover of a `.epub` file
|
|
41
|
+
- `compile` Takes EPUBs and/or chapter PDFs in a dir and compiles them into a single EPUB
|
|
42
|
+
- `append` Takes EPUBs and/or chapter PDFs in a dir and appends them to an existing EPUB
|
|
39
43
|
|
|
40
44
|
Run `epub-tools COMMAND --help` for details on options.
|
|
41
45
|
|
|
@@ -59,48 +63,84 @@ epub-tools pack -i epub_dir -o MyBook.epub
|
|
|
59
63
|
# Unpack EPUB
|
|
60
64
|
epub-tools unpack -i MyBook.epub -o unpacked_dir
|
|
61
65
|
|
|
66
|
+
# Add or replace the cover of an existing EPUB (keeps a MyBook.epub.bak backup)
|
|
67
|
+
epub-tools cover -i MyBook.epub -c cover.jpg
|
|
68
|
+
|
|
62
69
|
# Full compile workflow: extract, split, initialize, add, and pack into one EPUB
|
|
63
70
|
epub-tools compile -t "My Book" -a "Author Name" -s source_epubs -c cover.jpg -o MyBook.epub
|
|
71
|
+
|
|
72
|
+
# Append chapters from new EPUBs to an existing book
|
|
73
|
+
epub-tools append -s new_epubs -t MyBook.epub
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Chapter PDFs
|
|
77
|
+
`compile` and `append` also take a directory of chapter PDFs, one chapter per file, named
|
|
78
|
+
`<number><optional letters> <Title>.pdf`:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
0 Dramatis Personae.pdf -> "Dramatis Personae" (chapter 0 is front matter)
|
|
82
|
+
1 First Day.pdf -> "Chapter 1: First Day"
|
|
83
|
+
1a An Interlude.pdf -> "Chapter 1a: An Interlude", between 1 and 2
|
|
84
|
+
Epilogue One Month.pdf -> "Epilogue: One Month", after every chapter
|
|
64
85
|
```
|
|
65
86
|
|
|
66
|
-
(
|
|
87
|
+
Paragraphs, italics, bold and scene breaks (`***`) are rebuilt from the PDF layout, and the heading typed
|
|
88
|
+
inside the PDF is replaced by the one from the filename. To add new chapters later, put only the new PDFs
|
|
89
|
+
in a directory and `append` them; chapters are slotted into reading order, even a side chapter like `12a`
|
|
90
|
+
added after chapter 13 is already in the book. Chapters that are already in the book are refused.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
epub-tools compile -t "My Book" -a "Author Name" -s chapter_pdfs -o MyBook.epub
|
|
94
|
+
epub-tools append -s new_chapter_pdfs -t MyBook.epub
|
|
95
|
+
```
|
|
67
96
|
|
|
68
97
|
## Library Usage
|
|
69
98
|
Use the library directly in Ruby:
|
|
70
99
|
```ruby
|
|
71
100
|
require 'epub_tools'
|
|
72
101
|
|
|
73
|
-
#
|
|
102
|
+
# Full compile workflow: extract, split, and compile into a new EPUB
|
|
103
|
+
EpubTools::CompileBook.new(
|
|
104
|
+
title: 'My Book', author: 'Author Name',
|
|
105
|
+
source_dir: 'source_epubs', cover_image: 'cover.jpg',
|
|
106
|
+
output_file: 'MyBook.epub'
|
|
107
|
+
).run
|
|
108
|
+
|
|
109
|
+
# Append chapters from new EPUBs to an existing book
|
|
110
|
+
EpubTools::AppendBook.new(
|
|
111
|
+
source_dir: 'new_epubs',
|
|
112
|
+
target_epub: 'MyBook.epub'
|
|
113
|
+
).run
|
|
114
|
+
|
|
115
|
+
# Individual steps can also be used standalone:
|
|
116
|
+
|
|
117
|
+
# Extract XHTML files from EPUBs
|
|
74
118
|
EpubTools::XHTMLExtractor.new(
|
|
75
|
-
source_dir: 'source_epubs',
|
|
76
|
-
|
|
77
|
-
verbose: true
|
|
78
|
-
).extract_all
|
|
119
|
+
source_dir: 'source_epubs', target_dir: 'xhtml_output'
|
|
120
|
+
).run
|
|
79
121
|
|
|
80
|
-
# Split
|
|
122
|
+
# Split a multi-chapter XHTML into individual chapter files
|
|
81
123
|
EpubTools::SplitChapters.new(
|
|
82
|
-
'xhtml_output/chapter1.xhtml',
|
|
83
|
-
|
|
84
|
-
'chapters',
|
|
85
|
-
'chapter'
|
|
124
|
+
input_file: 'xhtml_output/chapter1.xhtml', book_title: 'My Book',
|
|
125
|
+
output_dir: 'chapters'
|
|
86
126
|
).run
|
|
87
127
|
|
|
88
|
-
# Initialize EPUB
|
|
128
|
+
# Initialize a new EPUB directory structure
|
|
89
129
|
EpubTools::EpubInitializer.new(
|
|
90
|
-
'My Book',
|
|
91
|
-
'
|
|
92
|
-
'epub_dir',
|
|
93
|
-
'cover.jpg'
|
|
130
|
+
title: 'My Book', author: 'Author Name',
|
|
131
|
+
destination: 'epub_dir', cover_image: 'cover.jpg'
|
|
94
132
|
).run
|
|
95
133
|
|
|
96
|
-
# Add
|
|
97
|
-
EpubTools::AddChapters.new(
|
|
134
|
+
# Add chapter files into an EPUB
|
|
135
|
+
EpubTools::AddChapters.new(
|
|
136
|
+
chapters_dir: 'chapters', oebps_dir: 'epub_dir/OEBPS'
|
|
137
|
+
).run
|
|
98
138
|
|
|
99
|
-
#
|
|
100
|
-
EpubTools::PackEbook.new('epub_dir', 'MyBook.epub').run
|
|
139
|
+
# Package an EPUB directory into a .epub file
|
|
140
|
+
EpubTools::PackEbook.new(input_dir: 'epub_dir', output_file: 'MyBook.epub').run
|
|
101
141
|
|
|
102
|
-
# Unpack
|
|
103
|
-
EpubTools::UnpackEbook.new('MyBook.epub', 'unpacked_dir').run
|
|
142
|
+
# Unpack a .epub file into a directory
|
|
143
|
+
EpubTools::UnpackEbook.new(epub_file: 'MyBook.epub', output_dir: 'unpacked_dir').run
|
|
104
144
|
```
|
|
105
145
|
## Development & Testing
|
|
106
146
|
Clone the repo and install dependencies:
|
data/bin/epub-tools
CHANGED
data/epub_tools.gemspec
CHANGED
|
@@ -13,14 +13,13 @@ Gem::Specification.new do |spec|
|
|
|
13
13
|
spec.files = `git ls-files`.split("\n")
|
|
14
14
|
spec.require_paths = ['lib']
|
|
15
15
|
spec.executables = ['epub-tools']
|
|
16
|
-
spec.required_ruby_version = '>=
|
|
16
|
+
spec.required_ruby_version = '>= 4.0'
|
|
17
17
|
spec.metadata = {
|
|
18
18
|
'source_code_uri' => 'https://github.com/jaimerodas/epub_tools/tree/main',
|
|
19
19
|
'homepage_uri' => 'https://github.com/jaimerodas/epub_tools',
|
|
20
20
|
'rubygems_mfa_required' => 'true'
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
spec.add_dependency 'nokogiri', '~> 1.
|
|
24
|
-
spec.add_dependency '
|
|
25
|
-
spec.add_dependency 'rubyzip', '~> 3.2'
|
|
23
|
+
spec.add_dependency 'nokogiri', '~> 1.19'
|
|
24
|
+
spec.add_dependency 'rubyzip', '~> 3.7'
|
|
26
25
|
end
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'nokogiri'
|
|
5
4
|
require 'fileutils'
|
|
6
5
|
require_relative 'loggable'
|
|
6
|
+
require_relative 'chapter_order'
|
|
7
7
|
|
|
8
8
|
module EpubTools
|
|
9
9
|
# Moves new chapters into an unpacked EPUB
|
|
@@ -32,9 +32,10 @@ module EpubTools
|
|
|
32
32
|
# It works like this:
|
|
33
33
|
# - First, the *.xhtml files are moved from +chapters_dir+ over to +epub_dir+
|
|
34
34
|
# - Then, new entries will be added to the manifest and spine of the EPUB's +package.opf+ file.
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
35
|
+
# Chapters are placed in reading order by their number (+chapter_12a+ goes between 12 and 13),
|
|
36
|
+
# even when the book already contains later chapters.
|
|
37
|
+
# - Finally, it will update the +nav.xhtml+ file with the new chapters, labelled with each chapter's
|
|
38
|
+
# <tt><h1></tt>. Without one, the label comes from the filename (+chapter_0.xhtml+ is the Prologue).
|
|
38
39
|
# @return [Array<String>] List of moved chapter filenames
|
|
39
40
|
def run
|
|
40
41
|
moved_files = move_chapters
|
|
@@ -59,10 +60,8 @@ module EpubTools
|
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def move_chapters
|
|
62
|
-
# Sort by chapter number (numeric)
|
|
63
63
|
chapter_files = Dir.glob(File.join(@chapters_dir, '*.xhtml')).sort_by do |path|
|
|
64
|
-
|
|
65
|
-
File.basename(path)[/\d+/].to_i
|
|
64
|
+
ChapterOrder.sort_key(path)
|
|
66
65
|
end
|
|
67
66
|
|
|
68
67
|
raise ArgumentError, "No .xhtml files found in '#{@chapters_dir}'" if chapter_files.empty?
|
|
@@ -74,7 +73,7 @@ module EpubTools
|
|
|
74
73
|
end
|
|
75
74
|
|
|
76
75
|
def chapter_id(filename)
|
|
77
|
-
match = filename.match(/chapter_(\d+)\.xhtml/)
|
|
76
|
+
match = filename.match(/chapter_(\d+(?:_5)?)\.xhtml/)
|
|
78
77
|
match ? "chap#{match[1]}" : File.basename(filename, '.xhtml')
|
|
79
78
|
end
|
|
80
79
|
|
|
@@ -92,7 +91,9 @@ module EpubTools
|
|
|
92
91
|
doc = Nokogiri::XML(File.read(@nav_file)) { |config| config.default_xml.noblanks }
|
|
93
92
|
nav = doc.at_xpath('//xmlns:nav[@epub:type="toc"]/xmlns:ol')
|
|
94
93
|
|
|
95
|
-
filenames.each
|
|
94
|
+
filenames.each do |filename|
|
|
95
|
+
ChapterOrder.insert(nav, create_nav_link(doc, filename), filename) { |li| li.at_xpath('xmlns:a')&.[]('href') }
|
|
96
|
+
end
|
|
96
97
|
|
|
97
98
|
File.write(@nav_file, doc.to_xml(indent: 2))
|
|
98
99
|
end
|
|
@@ -106,15 +107,25 @@ module EpubTools
|
|
|
106
107
|
li
|
|
107
108
|
end
|
|
108
109
|
|
|
110
|
+
# Uses the chapter's own <h1> when it has one, falling back to a label derived from the filename
|
|
109
111
|
def format_chapter_label(filename)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
heading = Nokogiri::XML(File.read(File.join(@epub_dir, filename))).at_xpath('//*[local-name()="h1"]')
|
|
113
|
+
return heading.text.strip unless heading.nil? || heading.text.strip.empty?
|
|
114
|
+
|
|
115
|
+
basename = File.basename(filename, '.xhtml')
|
|
116
|
+
return 'Prologue' if basename == 'chapter_0'
|
|
117
|
+
|
|
118
|
+
if (m = basename.match(/chapter_(\d+)_5/))
|
|
119
|
+
"Chapter #{m[1]}.5"
|
|
120
|
+
else
|
|
121
|
+
basename.gsub('_', ' ').capitalize
|
|
122
|
+
end
|
|
112
123
|
end
|
|
113
124
|
|
|
114
125
|
def update_opf_for_file(doc, manifest, spine, filename)
|
|
115
126
|
id = chapter_id(filename)
|
|
116
127
|
add_manifest_item(doc, manifest, filename, id) unless manifest_item_exists?(doc, filename)
|
|
117
|
-
add_spine_itemref(doc, spine, id) unless spine_itemref_exists?(doc, id)
|
|
128
|
+
add_spine_itemref(doc, spine, id, filename) unless spine_itemref_exists?(doc, id)
|
|
118
129
|
end
|
|
119
130
|
|
|
120
131
|
def manifest_item_exists?(doc, filename)
|
|
@@ -133,10 +144,11 @@ module EpubTools
|
|
|
133
144
|
manifest.add_child(item)
|
|
134
145
|
end
|
|
135
146
|
|
|
136
|
-
def add_spine_itemref(doc, spine, id)
|
|
147
|
+
def add_spine_itemref(doc, spine, id, filename)
|
|
137
148
|
itemref = Nokogiri::XML::Node.new('itemref', doc)
|
|
138
149
|
itemref['idref'] = id
|
|
139
|
-
|
|
150
|
+
hrefs = doc.xpath('//xmlns:manifest/xmlns:item').to_h { |item| [item['id'], item['href']] }
|
|
151
|
+
ChapterOrder.insert(spine, itemref, filename) { |ref| hrefs[ref['idref']] }
|
|
140
152
|
end
|
|
141
153
|
end
|
|
142
154
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require_relative 'book_builder'
|
|
5
|
+
require_relative 'unpack_ebook'
|
|
6
|
+
|
|
7
|
+
module EpubTools
|
|
8
|
+
# Appends chapters from source EPUBs to an existing target EPUB
|
|
9
|
+
class AppendBook < BookBuilder
|
|
10
|
+
attr_reader :target_epub
|
|
11
|
+
|
|
12
|
+
def initialize(options = {})
|
|
13
|
+
super
|
|
14
|
+
@target_epub = File.expand_path(options.fetch(:target_epub))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def book_title
|
|
20
|
+
@book_title ||= read_target_title
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def output_path = @target_epub
|
|
24
|
+
|
|
25
|
+
def prepare_epub
|
|
26
|
+
backup_target
|
|
27
|
+
unpack_target
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def before_add_chapters
|
|
31
|
+
detect_conflicts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finalize_and_cleanup
|
|
35
|
+
log "Done. Updated EPUB: #{@target_epub} (backup: #{@backup_path})"
|
|
36
|
+
FileUtils.rm_rf(build_dir)
|
|
37
|
+
@target_epub
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def backup_target
|
|
41
|
+
@backup_path = "#{@target_epub}.bak"
|
|
42
|
+
log "Backing up target to '#{@backup_path}'..."
|
|
43
|
+
FileUtils.cp(@target_epub, @backup_path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unpack_target
|
|
47
|
+
log 'Unpacking target EPUB...'
|
|
48
|
+
UnpackEbook.new(epub_file: @target_epub, output_dir: epub_dir, verbose: verbose).run
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_target_title
|
|
52
|
+
opf_path = File.join(epub_oebps_dir, 'package.opf')
|
|
53
|
+
doc = Nokogiri::XML(File.read(opf_path))
|
|
54
|
+
doc.remove_namespaces!
|
|
55
|
+
doc.at_xpath('//title')&.text || 'Untitled'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def detect_conflicts
|
|
59
|
+
conflicts = chapter_numbers_in(chapters_dir) & chapter_numbers_in(epub_oebps_dir)
|
|
60
|
+
return if conflicts.empty?
|
|
61
|
+
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"Chapter number conflict: chapters #{conflicts.sort_by(&:to_f).join(', ')} already exist in the " \
|
|
64
|
+
'target EPUB. Renumber the source chapters or remove conflicting chapters from the target.'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Array<String>] Chapter numbers as written in filenames: chapter_3_5 is "3.5", chapter_3a is "3a"
|
|
68
|
+
def chapter_numbers_in(dir)
|
|
69
|
+
Dir.glob(File.join(dir, 'chapter_*.xhtml')).map do |path|
|
|
70
|
+
File.basename(path, '.xhtml').delete_prefix('chapter_').tr('_', '.')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|