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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a379e3e740873230fa8c4f71261006ac79654e74f1fa5ba299aee8742f9aa3c3
4
- data.tar.gz: c9d83418c1f585a43e3650e3b5189fd74a4f3374a4835b818bc15379f19ede05
3
+ metadata.gz: 79e280c3b4dd2f362ee8c013e98f6ac44a9ea5c18535bd0b6dba67c1a5a49f52
4
+ data.tar.gz: 1c6a8eac67cced477f7312eb5ec50091048c5bf5932264b5de9f1396c8bd6879
5
5
  SHA512:
6
- metadata.gz: 27bf72687e57c4998831d5e94098ddbeb76f953d99b09d2798e2087b4ce147353df2673e5d1996124047ae3c09741604b80483a79aea471180cdd9769f76a28f
7
- data.tar.gz: b06cce574ef93ab03769d7734bffd0947453bc4353f50c17edda34943a075e151a44e04916bc4c08890ff51b9f8a7f6d4fde7ba05d591507ba5be0bb6d49a65e
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,44 +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
71
+ - `SetCover`: Adds or replaces a cover, in a packed EPUB (`cover` command) or an unpacked one (used by `EpubInitializer`)
74
72
  - **Workflow Classes**: Orchestrators built on a shared base class
75
- - `BookBuilder`: Base class with template method pattern (extract → split → validate → add → pack)
73
+ - `BookBuilder`: Base class with template method pattern (extract → split → convert PDFs → validate → add → pack)
76
74
  - `CompileBook`: Creates a new EPUB from source EPUBs (inherits BookBuilder)
77
75
  - `AppendBook`: Appends chapters from source EPUBs to an existing EPUB (inherits BookBuilder)
78
- - **Supporting Classes**: SOLID-designed helper classes
79
- - `CompileWorkspace`: Manages build directories for book-building workflows
76
+ - **Supporting Classes**
80
77
  - `ChapterValidator`: Validates chapter sequence completeness
81
- - `ChapterMarkerDetector`: Detects chapter boundary markers (Chapter N, Chapter N (continued), Prologue)
82
- - `EpubConfiguration`: Configuration object for EPUB initialization
83
- - `XhtmlGenerator`: Generates XHTML templates for EPUB content
84
- - `EpubMetadataBuilder`: Builds OPF metadata content
85
- - `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
86
81
 
87
82
  ### CLI Architecture
88
83
 
89
- The CLI uses a registry-based system where:
90
- 1. Commands are registered in `cli.rb` with their class, required parameters, and defaults
91
- 2. The `Runner` dispatches to the appropriate command class
92
- 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`
93
87
  4. Each command class implements a `run` method and uses the `Loggable` mixin for verbose output
94
88
 
95
89
  ### Dependencies
96
90
 
97
91
  - **nokogiri**: XML/HTML parsing for EPUB content
98
92
  - **rubyzip**: ZIP file manipulation for EPUB packaging
99
- - **rake**: Build tasks and testing
93
+ - **rake**: Build tasks and testing (development only)
100
94
  - **minitest**: Testing framework
101
95
  - **rubocop**: Code linting with custom configuration
102
96
  - **simplecov**: Test coverage reporting
@@ -115,14 +109,12 @@ The CLI uses a registry-based system where:
115
109
  Tests use Minitest with:
116
110
  - `test_helper.rb` sets up SimpleCov coverage
117
111
  - Tests in `test/` directory follow `*_test.rb` naming
118
- - CLI tests verify command registration and option parsing
112
+ - CLI tests verify option parsing and dispatch; `cli_commands_test.rb` runs the real executable
119
113
  - Individual component tests verify core functionality
120
114
 
121
115
  ### Code Quality
122
116
 
123
- The codebase follows SOLID principles with:
124
- - **Single Responsibility**: Classes have focused, well-defined purposes
125
- - **Open/Closed**: Extensible design through composition and dependency injection
126
- - **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
127
119
 
128
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.19.2)
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
@@ -7,7 +7,8 @@
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,11 +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
39
- - `append` Extracts and splits EPUBs from a dir and appends them to an existing 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
40
43
 
41
44
  Run `epub-tools COMMAND --help` for details on options.
42
45
 
@@ -60,6 +63,9 @@ epub-tools pack -i epub_dir -o MyBook.epub
60
63
  # Unpack EPUB
61
64
  epub-tools unpack -i MyBook.epub -o unpacked_dir
62
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
+
63
69
  # Full compile workflow: extract, split, initialize, add, and pack into one EPUB
64
70
  epub-tools compile -t "My Book" -a "Author Name" -s source_epubs -c cover.jpg -o MyBook.epub
65
71
 
@@ -67,6 +73,27 @@ epub-tools compile -t "My Book" -a "Author Name" -s source_epubs -c cover.jpg -o
67
73
  epub-tools append -s new_epubs -t MyBook.epub
68
74
  ```
69
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
85
+ ```
86
+
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
+ ```
96
+
70
97
  ## Library Usage
71
98
  Use the library directly in Ruby:
72
99
  ```ruby
@@ -95,7 +122,7 @@ EpubTools::XHTMLExtractor.new(
95
122
  # Split a multi-chapter XHTML into individual chapter files
96
123
  EpubTools::SplitChapters.new(
97
124
  input_file: 'xhtml_output/chapter1.xhtml', book_title: 'My Book',
98
- output_dir: 'chapters', output_prefix: 'chapter'
125
+ output_dir: 'chapters'
99
126
  ).run
100
127
 
101
128
  # Initialize a new EPUB directory structure
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
@@ -60,7 +61,7 @@ module EpubTools
60
61
 
61
62
  def move_chapters
62
63
  chapter_files = Dir.glob(File.join(@chapters_dir, '*.xhtml')).sort_by do |path|
63
- chapter_sort_key(File.basename(path))
64
+ ChapterOrder.sort_key(path)
64
65
  end
65
66
 
66
67
  raise ArgumentError, "No .xhtml files found in '#{@chapters_dir}'" if chapter_files.empty?
@@ -71,15 +72,6 @@ module EpubTools
71
72
  chapter_files.map { |f| File.basename(f) }
72
73
  end
73
74
 
74
- def chapter_sort_key(filename)
75
- basename = File.basename(filename, '.xhtml')
76
- if (m = basename.match(/_(\d+)_5\z/))
77
- m[1].to_f + 0.5
78
- else
79
- basename[/\d+/].to_f
80
- end
81
- end
82
-
83
75
  def chapter_id(filename)
84
76
  match = filename.match(/chapter_(\d+(?:_5)?)\.xhtml/)
85
77
  match ? "chap#{match[1]}" : File.basename(filename, '.xhtml')
@@ -99,7 +91,9 @@ module EpubTools
99
91
  doc = Nokogiri::XML(File.read(@nav_file)) { |config| config.default_xml.noblanks }
100
92
  nav = doc.at_xpath('//xmlns:nav[@epub:type="toc"]/xmlns:ol')
101
93
 
102
- 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
103
97
 
104
98
  File.write(@nav_file, doc.to_xml(indent: 2))
105
99
  end
@@ -113,7 +107,11 @@ module EpubTools
113
107
  li
114
108
  end
115
109
 
110
+ # Uses the chapter's own <h1> when it has one, falling back to a label derived from the filename
116
111
  def format_chapter_label(filename)
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
+
117
115
  basename = File.basename(filename, '.xhtml')
118
116
  return 'Prologue' if basename == 'chapter_0'
119
117
 
@@ -127,7 +125,7 @@ module EpubTools
127
125
  def update_opf_for_file(doc, manifest, spine, filename)
128
126
  id = chapter_id(filename)
129
127
  add_manifest_item(doc, manifest, filename, id) unless manifest_item_exists?(doc, filename)
130
- 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)
131
129
  end
132
130
 
133
131
  def manifest_item_exists?(doc, filename)
@@ -146,10 +144,11 @@ module EpubTools
146
144
  manifest.add_child(item)
147
145
  end
148
146
 
149
- def add_spine_itemref(doc, spine, id)
147
+ def add_spine_itemref(doc, spine, id, filename)
150
148
  itemref = Nokogiri::XML::Node.new('itemref', doc)
151
149
  itemref['idref'] = id
152
- 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']] }
153
152
  end
154
153
  end
155
154
  end
@@ -33,7 +33,7 @@ module EpubTools
33
33
 
34
34
  def finalize_and_cleanup
35
35
  log "Done. Updated EPUB: #{@target_epub} (backup: #{@backup_path})"
36
- @workspace.clean
36
+ FileUtils.rm_rf(build_dir)
37
37
  @target_epub
38
38
  end
39
39
 
@@ -45,7 +45,7 @@ module EpubTools
45
45
 
46
46
  def unpack_target
47
47
  log 'Unpacking target EPUB...'
48
- UnpackEbook.new(epub_file: @target_epub, output_dir: @workspace.epub_dir, verbose: verbose).run
48
+ UnpackEbook.new(epub_file: @target_epub, output_dir: epub_dir, verbose: verbose).run
49
49
  end
50
50
 
51
51
  def read_target_title
@@ -56,25 +56,18 @@ module EpubTools
56
56
  end
57
57
 
58
58
  def detect_conflicts
59
- new_numbers = chapter_numbers_in(@workspace.chapters_dir)
60
- existing_numbers = chapter_numbers_in(epub_oebps_dir)
61
- conflicts = new_numbers & existing_numbers
59
+ conflicts = chapter_numbers_in(chapters_dir) & chapter_numbers_in(epub_oebps_dir)
62
60
  return if conflicts.empty?
63
61
 
64
- formatted = conflicts.sort.map { |n| n == n.to_i ? n.to_i.to_s : n.to_s }
65
62
  raise ArgumentError,
66
- "Chapter number conflict: chapters #{formatted.join(', ')} already exist in the target EPUB. " \
67
- 'Renumber the source chapters or remove conflicting chapters from the target.'
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.'
68
65
  end
69
66
 
67
+ # @return [Array<String>] Chapter numbers as written in filenames: chapter_3_5 is "3.5", chapter_3a is "3a"
70
68
  def chapter_numbers_in(dir)
71
- Dir.glob(File.join(dir, 'chapter_*.xhtml')).filter_map do |path|
72
- basename = File.basename(path, '.xhtml')
73
- if (m = basename.match(/_(\d+)_5\z/))
74
- m[1].to_f + 0.5
75
- elsif (m = basename.match(/_(\d+)\z/))
76
- m[1].to_f
77
- end
69
+ Dir.glob(File.join(dir, 'chapter_*.xhtml')).map do |path|
70
+ File.basename(path, '.xhtml').delete_prefix('chapter_').tr('_', '.')
78
71
  end
79
72
  end
80
73
  end