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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 830a3d6d88e106980c19f5c19ea5e0365e39499cc90c0c32ff5c204b7cf24abd
4
- data.tar.gz: 7898e152850e5e390842e04a56947e2e88623bd1e5b65525561423da7a5ec744
3
+ metadata.gz: 79e280c3b4dd2f362ee8c013e98f6ac44a9ea5c18535bd0b6dba67c1a5a49f52
4
+ data.tar.gz: 1c6a8eac67cced477f7312eb5ec50091048c5bf5932264b5de9f1396c8bd6879
5
5
  SHA512:
6
- metadata.gz: 7b852a22e630ad9177c5adc692326dac517b904c687e6cbe3874b7e1f43347a53cfdd381cb5b66b7feaf6faafae75129510aac6a23f84f4b3e82734ae84dc8ac
7
- data.tar.gz: 4602e5922dbdf257436445546b14501a0064861af84fea854feb0a10ed2ef651d6eefb8009ce459c7463c50c3383eabe3220a10d856271ad38c49cc768b8da5f
6
+ metadata.gz: fe396cf56d97ee602ad326f50614f21edc31b0af9ef0ed82656826f567e5dc5ad7df92620e1ba6b79203dbabac75ee03cff2c2ec597cb8763b35ac601ee72a79
7
+ data.tar.gz: 6a293f1075e4ff4b2ee26a2e11c2aedc5ec39b56d638dacb0c8feae916eb405024be0006b7fa4310afa333b871429130c6ec2265f236b9b7e998591103066ba4
@@ -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@v3
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
- ruby-version: ${{ matrix.ruby }}
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
@@ -6,6 +6,5 @@
6
6
  vendor/bundle/
7
7
  *.gem
8
8
 
9
- text_style_classes.yaml
10
9
 
11
10
  **/.claude/
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.4.5
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 System** (`lib/epub_tools/cli/`): Object-oriented command-line interface
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
- - `SplitChapters`: Splits XHTML files into separate chapters
70
- - `EpubInitializer`: Creates new EPUB directory structure (uses configuration pattern)
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
- - `CompileBook`: Full workflow combining multiple operations (uses workspace pattern)
75
- - **Supporting Classes**: SOLID-designed helper classes
76
- - `CompileWorkspace`: Manages build directories for CompileBook
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
- - `EpubConfiguration`: Configuration object for EPUB initialization
79
- - `XhtmlGenerator`: Generates XHTML templates for EPUB content
80
- - `EpubMetadataBuilder`: Builds OPF metadata content
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
- The CLI uses a registry-based system where:
86
- 1. Commands are registered in `cli.rb` with their class, required parameters, and defaults
87
- 2. The `Runner` dispatches to the appropriate command class
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 command registration and option parsing
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
- The codebase follows SOLID principles with:
120
- - **Single Responsibility**: Classes have focused, well-defined purposes
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
- gem 'nokogiri', '~> 1.18'
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.39.0', require: false
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', '~> 7.2'
17
+ gem 'rdoc', '~> 8.1'
21
18
  gem 'webrick', '~> 1.9'
22
- gem 'yard', '~> 0.9.37'
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.2)
9
- json (2.18.1)
10
- language_server-protocol (3.17.0.5)
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
- minitest (6.0.2)
17
+ logger (1.7.0)
18
+ minitest (6.1.0)
13
19
  drb (~> 2.0)
14
- prism (~> 1.5)
15
- nokogiri (1.19.1-aarch64-linux-gnu)
20
+ prism (>= 1.5, < 3)
21
+ nokogiri (1.19.7-aarch64-linux-gnu)
16
22
  racc (~> 1.4)
17
- nokogiri (1.19.1-aarch64-linux-musl)
23
+ nokogiri (1.19.7-aarch64-linux-musl)
18
24
  racc (~> 1.4)
19
- nokogiri (1.19.1-arm-linux-gnu)
25
+ nokogiri (1.19.7-arm-linux-gnu)
20
26
  racc (~> 1.4)
21
- nokogiri (1.19.1-arm-linux-musl)
27
+ nokogiri (1.19.7-arm-linux-musl)
22
28
  racc (~> 1.4)
23
- nokogiri (1.19.1-arm64-darwin)
29
+ nokogiri (1.19.7-arm64-darwin)
24
30
  racc (~> 1.4)
25
- nokogiri (1.19.1-x86_64-darwin)
31
+ nokogiri (1.19.7-x86_64-darwin)
26
32
  racc (~> 1.4)
27
- nokogiri (1.19.1-x86_64-linux-gnu)
33
+ nokogiri (1.19.7-x86_64-linux-gnu)
28
34
  racc (~> 1.4)
29
- nokogiri (1.19.1-x86_64-linux-musl)
35
+ nokogiri (1.19.7-x86_64-linux-musl)
30
36
  racc (~> 1.4)
31
- parallel (1.27.0)
32
- parser (3.3.10.2)
37
+ parallel (2.3.0)
38
+ parser (3.3.13.0)
33
39
  ast (~> 2.4.1)
34
40
  racc
35
- prism (1.9.0)
36
- psych (5.3.1)
37
- date
38
- stringio
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.3.1)
42
- rdoc (7.2.0)
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
- psych (>= 4.0.0)
59
+ prism (>= 1.6.0)
60
+ rbs (>= 4.0.0)
45
61
  tsort
46
- regexp_parser (2.11.3)
47
- rubocop (1.84.2)
48
- json (~> 2.3)
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 (~> 1.10)
67
+ parallel (>= 1.10)
52
68
  parser (>= 3.3.0.2)
53
69
  rainbow (>= 2.2.2, < 4.0)
54
- regexp_parser (>= 2.9.3, < 3.0)
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.49.0)
74
+ rubocop-ast (1.51.0)
59
75
  parser (>= 3.3.7.2)
60
- prism (~> 1.7)
61
- rubocop-minitest (0.39.1)
76
+ prism (>= 1.7)
77
+ rubocop-minitest (0.41.0)
62
78
  lint_roller (~> 1.1)
63
- rubocop (>= 1.75.0, < 2.0)
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.2.2)
70
- simplecov (0.22.0)
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.2.0)
79
- unicode-emoji (~> 4.1)
80
- unicode-emoji (4.2.0)
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.38)
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 (~> 7.2)
108
+ rdoc (~> 8.1)
99
109
  rubocop (~> 1.75)
100
- rubocop-minitest (~> 0.39.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.37)
106
-
107
- RUBY VERSION
108
- ruby 3.4.5p51
114
+ yard (~> 0.9.44)
109
115
 
110
116
  BUNDLED WITH
111
- 2.7.0
117
+ 4.0.24
data/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  [![Build Status](https://github.com/jaimerodas/epub_tools/actions/workflows/ci.yml/badge.svg)](https://github.com/jaimerodas/epub_tools/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Gem Version](https://badge.fury.io/rb/epub_tools.svg)](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 unpack EPUB books.
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 3.2 or higher
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
- - `compile` Takes EPUBs in a dir and splits, cleans, and compiles into a single EPUB
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
- (Legacy script references removed; see CLI Usage above)
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
- # Extract XHTML
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
- target_dir: 'xhtml_output',
77
- verbose: true
78
- ).extract_all
119
+ source_dir: 'source_epubs', target_dir: 'xhtml_output'
120
+ ).run
79
121
 
80
- # Split chapters
122
+ # Split a multi-chapter XHTML into individual chapter files
81
123
  EpubTools::SplitChapters.new(
82
- 'xhtml_output/chapter1.xhtml',
83
- 'My Book',
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
- 'Author Name',
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 chapters
97
- EpubTools::AddChapters.new('chapters', 'epub_dir/OEBPS').run
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
- # Pack EPUB
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 EPUB
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
@@ -3,6 +3,4 @@
3
3
 
4
4
  require_relative '../lib/epub_tools'
5
5
 
6
- # Use the new object-oriented CLI architecture
7
- runner = EpubTools::CLI.create_runner
8
- runner.run(ARGV)
6
+ EpubTools::CLI.run(ARGV)
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 = '>= 3.2'
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.18'
24
- spec.add_dependency 'rake', '~> 13.2'
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
- # It will sort the files by extracting the chapter number.
36
- # - Finally, it will update the +nav.xhtml+ file with the new chapters. Note that if there's a
37
- # file named +chapter_0.xhtml+, it will be added to the +nav.xhtml+ as the Prologue.
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
- # extract first integer from filename (e.g. chapter_10.xhtml -> 10)
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 { |filename| nav.add_child(create_nav_link(doc, filename)) }
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
- label = File.basename(filename, '.xhtml').gsub('_', ' ').capitalize
111
- label == 'Chapter 0' ? 'Prologue' : label
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
- spine.add_child(itemref)
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