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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +3 -8
- data/.gitignore +0 -1
- data/.tool-versions +1 -1
- data/CLAUDE.md +17 -25
- data/Gemfile +5 -8
- data/Gemfile.lock +60 -54
- data/README.md +31 -4
- data/bin/epub-tools +1 -3
- data/epub_tools.gemspec +3 -4
- data/lib/epub_tools/add_chapters.rb +17 -18
- data/lib/epub_tools/append_book.rb +8 -15
- data/lib/epub_tools/book_builder.rb +28 -22
- data/lib/epub_tools/chapter_order.rb +33 -0
- data/lib/epub_tools/chapter_validator.rb +6 -3
- data/lib/epub_tools/cli.rb +121 -23
- data/lib/epub_tools/compile_book.rb +1 -1
- 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 +22 -20
- 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 +2 -1
- data/test/add_chapters_test.rb +64 -0
- data/test/append_book_test.rb +11 -11
- data/test/chapter_validator_test.rb +12 -0
- data/test/cli_test.rb +40 -3
- data/test/compile_book_test.rb +14 -0
- 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 +18 -12
- 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 +14 -35
- data/lib/epub_tools/chapter_marker_detector.rb +0 -46
- data/lib/epub_tools/cli/command_options_configurator.rb +0 -128
- 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 -114
- 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
data/test/add_chapters_test.rb
CHANGED
|
@@ -191,3 +191,67 @@ class AddChaptersHalfChapterTest < Minitest::Test
|
|
|
191
191
|
XHTML
|
|
192
192
|
end
|
|
193
193
|
end
|
|
194
|
+
|
|
195
|
+
class AddChaptersReadingOrderTest < Minitest::Test
|
|
196
|
+
def setup
|
|
197
|
+
@tmp = Dir.mktmpdir
|
|
198
|
+
@chapters_dir = File.join(@tmp, 'chapters')
|
|
199
|
+
@epub_dir = File.join(@tmp, 'OEBPS')
|
|
200
|
+
FileUtils.mkdir_p([@chapters_dir, @epub_dir])
|
|
201
|
+
create_book_with_two_chapters
|
|
202
|
+
write_chapter('chapter_1b.xhtml', '<h1>Chapter 1b: Later</h1>')
|
|
203
|
+
write_chapter('chapter_1a.xhtml', '<h1>Chapter 1a: Side</h1>')
|
|
204
|
+
write_chapter('chapter_3.xhtml', '<p>No heading</p>')
|
|
205
|
+
write_chapter('chapter_epilogue.xhtml', '<h1>Epilogue: After</h1>')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def teardown
|
|
209
|
+
FileUtils.remove_entry(@tmp)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def test_inserts_chapters_in_reading_order_with_their_headings
|
|
213
|
+
EpubTools::AddChapters.new(chapters_dir: @chapters_dir, oebps_dir: @epub_dir).run
|
|
214
|
+
|
|
215
|
+
opf = Nokogiri::XML(File.read(File.join(@epub_dir, 'package.opf')))
|
|
216
|
+
nav = Nokogiri::XML(File.read(File.join(@epub_dir, 'nav.xhtml'))).remove_namespaces!
|
|
217
|
+
spine = opf.xpath('//xmlns:itemref').map { |i| i['idref'] }
|
|
218
|
+
|
|
219
|
+
assert_equal %w[title chap1 chapter_1a chapter_1b chap2 chap3 chapter_epilogue], spine
|
|
220
|
+
assert_equal ['Title Page', 'Chapter 1', 'Chapter 1a: Side', 'Chapter 1b: Later', 'Chapter 2', 'Chapter 3',
|
|
221
|
+
'Epilogue: After'],
|
|
222
|
+
nav.xpath('//nav/ol/li/a').map(&:text)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def write_chapter(name, body)
|
|
228
|
+
File.write(File.join(@chapters_dir, name), "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>#{body}</body></html>")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def create_book_with_two_chapters
|
|
232
|
+
File.write(File.join(@epub_dir, 'package.opf'), <<~XML)
|
|
233
|
+
<?xml version="1.0"?>
|
|
234
|
+
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="en">
|
|
235
|
+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"></metadata>
|
|
236
|
+
<manifest>
|
|
237
|
+
<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>
|
|
238
|
+
<item id="chap1" href="chapter_1.xhtml" media-type="application/xhtml+xml"/>
|
|
239
|
+
<item id="chap2" href="chapter_2.xhtml" media-type="application/xhtml+xml"/>
|
|
240
|
+
</manifest>
|
|
241
|
+
<spine><itemref idref="title"/><itemref idref="chap1"/><itemref idref="chap2"/></spine>
|
|
242
|
+
</package>
|
|
243
|
+
XML
|
|
244
|
+
File.write(File.join(@epub_dir, 'nav.xhtml'), <<~XHTML)
|
|
245
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
246
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
|
|
247
|
+
<body>
|
|
248
|
+
<nav epub:type="toc" id="toc"><ol>
|
|
249
|
+
<li><a href="title.xhtml">Title Page</a></li>
|
|
250
|
+
<li><a href="chapter_1.xhtml">Chapter 1</a></li>
|
|
251
|
+
<li><a href="chapter_2.xhtml">Chapter 2</a></li>
|
|
252
|
+
</ol></nav>
|
|
253
|
+
</body>
|
|
254
|
+
</html>
|
|
255
|
+
XHTML
|
|
256
|
+
end
|
|
257
|
+
end
|
data/test/append_book_test.rb
CHANGED
|
@@ -45,7 +45,7 @@ class AppendBookTest < Minitest::Test
|
|
|
45
45
|
|
|
46
46
|
def test_detect_conflicts_raises_on_overlap
|
|
47
47
|
ab = build_append_book
|
|
48
|
-
setup_conflict_dirs(
|
|
48
|
+
setup_conflict_dirs(new_chapters: [1, 2], existing_chapters: [1])
|
|
49
49
|
|
|
50
50
|
error = assert_raises(ArgumentError) { ab.send(:detect_conflicts) }
|
|
51
51
|
assert_match(/chapters 1 already exist/, error.message)
|
|
@@ -53,11 +53,19 @@ class AppendBookTest < Minitest::Test
|
|
|
53
53
|
|
|
54
54
|
def test_detect_conflicts_passes_with_no_overlap
|
|
55
55
|
ab = build_append_book
|
|
56
|
-
setup_conflict_dirs(
|
|
56
|
+
setup_conflict_dirs(new_chapters: [5], existing_chapters: [1, 2])
|
|
57
57
|
|
|
58
58
|
ab.send(:detect_conflicts)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
def test_detect_conflicts_with_lettered_chapters
|
|
62
|
+
ab = build_append_book
|
|
63
|
+
setup_conflict_dirs(new_chapters: %w[3a 4], existing_chapters: %w[3 3a])
|
|
64
|
+
|
|
65
|
+
error = assert_raises(ArgumentError) { ab.send(:detect_conflicts) }
|
|
66
|
+
assert_match(/chapters 3a already exist/, error.message)
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def test_detect_conflicts_with_half_chapters
|
|
62
70
|
ab = build_append_book
|
|
63
71
|
chapters_dir = File.join(@tmp, 'chapters')
|
|
@@ -67,10 +75,6 @@ class AppendBookTest < Minitest::Test
|
|
|
67
75
|
FileUtils.touch(File.join(chapters_dir, 'chapter_3_5.xhtml'))
|
|
68
76
|
FileUtils.touch(File.join(oebps_dir, 'chapter_3_5.xhtml'))
|
|
69
77
|
|
|
70
|
-
workspace = ab.instance_variable_get(:@workspace)
|
|
71
|
-
workspace.instance_variable_set(:@chapters_dir, chapters_dir)
|
|
72
|
-
workspace.instance_variable_set(:@epub_dir, File.join(@tmp, 'epub'))
|
|
73
|
-
|
|
74
78
|
error = assert_raises(ArgumentError) { ab.send(:detect_conflicts) }
|
|
75
79
|
assert_match(/3\.5 already exist/, error.message)
|
|
76
80
|
end
|
|
@@ -112,16 +116,12 @@ class AppendBookTest < Minitest::Test
|
|
|
112
116
|
)
|
|
113
117
|
end
|
|
114
118
|
|
|
115
|
-
def setup_conflict_dirs(
|
|
119
|
+
def setup_conflict_dirs(new_chapters:, existing_chapters:)
|
|
116
120
|
chapters_dir = File.join(@tmp, 'chapters')
|
|
117
121
|
oebps_dir = File.join(@tmp, 'epub', 'OEBPS')
|
|
118
122
|
FileUtils.mkdir_p([chapters_dir, oebps_dir])
|
|
119
123
|
|
|
120
124
|
new_chapters.each { |n| FileUtils.touch(File.join(chapters_dir, "chapter_#{n}.xhtml")) }
|
|
121
125
|
existing_chapters.each { |n| FileUtils.touch(File.join(oebps_dir, "chapter_#{n}.xhtml")) }
|
|
122
|
-
|
|
123
|
-
workspace = append_book.instance_variable_get(:@workspace)
|
|
124
|
-
workspace.instance_variable_set(:@chapters_dir, chapters_dir)
|
|
125
|
-
workspace.instance_variable_set(:@epub_dir, File.join(@tmp, 'epub'))
|
|
126
126
|
end
|
|
127
127
|
end
|
|
@@ -58,6 +58,18 @@ class ChapterValidatorTest < Minitest::Test
|
|
|
58
58
|
assert_match(/Missing chapter numbers: 2/, error.message)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
def test_lettered_chapters_are_not_required
|
|
62
|
+
create_chapter_files(%w[1 1a 1b 2])
|
|
63
|
+
|
|
64
|
+
assert_silent { @validator.validate }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_validates_only_side_chapters
|
|
68
|
+
create_chapter_files(%w[7a 7b])
|
|
69
|
+
|
|
70
|
+
assert_silent { @validator.validate }
|
|
71
|
+
end
|
|
72
|
+
|
|
61
73
|
private
|
|
62
74
|
|
|
63
75
|
def create_chapter_files(numbers)
|
data/test/cli_test.rb
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'test_helper'
|
|
4
|
-
require_relative '
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
require_relative '../lib/epub_tools'
|
|
5
|
+
|
|
6
|
+
class CLITest < Minitest::Test
|
|
7
|
+
def test_parse_maps_flags_to_option_keys
|
|
8
|
+
options = EpubTools::CLI.parse('init', %w[-t Title -a Author -o dir -c cover.jpg])
|
|
9
|
+
|
|
10
|
+
assert_equal({ verbose: true, title: 'Title', author: 'Author', destination: 'dir', cover_image: 'cover.jpg' },
|
|
11
|
+
options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_quiet_turns_off_verbose
|
|
15
|
+
refute EpubTools::CLI.parse('pack', %w[-i dir -o out.epub -q])[:verbose]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_commands_without_verbose_output_have_no_quiet_flag
|
|
19
|
+
assert_equal({ chapters_dir: 'c', oebps_dir: 'e' }, EpubTools::CLI.parse('add', %w[-c c -e e]))
|
|
20
|
+
assert_output('', /invalid option: -q/) do
|
|
21
|
+
assert_raises(SystemExit) { EpubTools::CLI.parse('add', %w[-c c -e e -q]) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_missing_required_options_name_their_flags
|
|
26
|
+
assert_output('', /Missing required options: --output-dir\n/) do
|
|
27
|
+
assert_raises(SystemExit) { EpubTools::CLI.parse('init', %w[-t Title -a Author]) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_run_builds_and_runs_the_command
|
|
32
|
+
Dir.mktmpdir do |dir|
|
|
33
|
+
File.write(File.join(dir, 'mimetype'), 'application/epub+zip')
|
|
34
|
+
out = File.join(dir, '..', "#{File.basename(dir)}.epub")
|
|
35
|
+
command = EpubTools::CLI.run(['pack', '-i', dir, '-o', out, '-q'])
|
|
36
|
+
|
|
37
|
+
assert_instance_of EpubTools::PackEbook, command
|
|
38
|
+
assert_path_exists out
|
|
39
|
+
ensure
|
|
40
|
+
FileUtils.rm_f(out)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/test/compile_book_test.rb
CHANGED
|
@@ -113,4 +113,18 @@ class CompileBookTest < Minitest::Test
|
|
|
113
113
|
|
|
114
114
|
assert_equal 'test.epub', result
|
|
115
115
|
end
|
|
116
|
+
|
|
117
|
+
def test_relative_output_is_written_to_working_directory
|
|
118
|
+
book = File.join(@tmp, 'part')
|
|
119
|
+
FileUtils.mkdir_p(File.join(book, 'OEBPS'))
|
|
120
|
+
File.write(File.join(book, 'mimetype'), 'application/epub+zip')
|
|
121
|
+
File.write(File.join(book, 'OEBPS', 'text.xhtml'), '<html><body><p>Chapter 1</p><p>Text</p></body></html>')
|
|
122
|
+
EpubTools::PackEbook.new(input_dir: book, output_file: File.join(@source, 'part.epub')).run
|
|
123
|
+
|
|
124
|
+
Dir.chdir(@tmp) do
|
|
125
|
+
EpubTools::CompileBook.new(title: @title, author: @author, source_dir: @source, output_file: 'out.epub').run
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
assert_path_exists File.join(@tmp, 'out.epub')
|
|
129
|
+
end
|
|
116
130
|
end
|
|
@@ -24,6 +24,22 @@ class EpubInitializerTest < Minitest::Test
|
|
|
24
24
|
verify_file_contents
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def test_style_comes_from_the_gem_not_the_working_directory
|
|
28
|
+
Dir.chdir(@tmp) { EpubTools::EpubInitializer.new(title: @title, author: @author, destination: @dest).run }
|
|
29
|
+
|
|
30
|
+
assert_equal File.read(File.expand_path('../style.css', __dir__)), File.read(File.join(@dest, 'OEBPS', 'style.css'))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_title_and_author_are_escaped
|
|
34
|
+
EpubTools::EpubInitializer.new(title: 'Tom & Jerry', author: 'A <B>', destination: @dest).run
|
|
35
|
+
opf = strict_xml('package.opf')
|
|
36
|
+
dc = { 'dc' => 'http://purl.org/dc/elements/1.1/' }
|
|
37
|
+
|
|
38
|
+
assert_equal 'Tom & Jerry', opf.at_xpath('//dc:title', dc).text
|
|
39
|
+
assert_equal 'A <B>', opf.at_xpath('//dc:creator', dc).text
|
|
40
|
+
assert_equal 'by A <B>', strict_xml('title.xhtml').at_xpath('//xmlns:p[@class="author"]').text
|
|
41
|
+
end
|
|
42
|
+
|
|
27
43
|
def test_run_with_cover_image
|
|
28
44
|
# create dummy image
|
|
29
45
|
cover = File.join(@tmp, 'cover.png')
|
|
@@ -117,6 +133,8 @@ class EpubInitializerTest < Minitest::Test
|
|
|
117
133
|
|
|
118
134
|
private
|
|
119
135
|
|
|
136
|
+
def strict_xml(name) = Nokogiri::XML(File.read(File.join(@dest, 'OEBPS', name)), &:strict)
|
|
137
|
+
|
|
120
138
|
def verify_directory_structure
|
|
121
139
|
assert Dir.exist?(@dest)
|
|
122
140
|
assert File.directory?(File.join(@dest, 'META-INF'))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_helper'
|
|
4
|
+
require_relative '../lib/epub_tools/pdf_converter'
|
|
5
|
+
|
|
6
|
+
class PDFConverterTest < Minitest::Test
|
|
7
|
+
XML = <<~XML
|
|
8
|
+
<pdf2xml><page number="1">
|
|
9
|
+
<fontspec id="0" size="42"/><fontspec id="1" size="18"/>
|
|
10
|
+
<text top="105" left="231" width="400" font="0">Heading typed in the PDF</text>
|
|
11
|
+
<text top="198" left="135" width="400" font="1">Tom & Jerry’s text.</text>
|
|
12
|
+
<text top="235" left="135" width="400" font="1">More text.</text>
|
|
13
|
+
</page></pdf2xml>
|
|
14
|
+
XML
|
|
15
|
+
|
|
16
|
+
def setup
|
|
17
|
+
@tmp = Dir.mktmpdir
|
|
18
|
+
@source = File.join(@tmp, 'pdfs')
|
|
19
|
+
@output = File.join(@tmp, 'chapters')
|
|
20
|
+
FileUtils.mkdir_p(@source)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def teardown
|
|
24
|
+
FileUtils.rm_rf(@tmp)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_names_chapters_after_their_pdfs
|
|
28
|
+
['0 Cast List.pdf', "12a Don't Stop.pdf"].each { |name| FileUtils.touch(File.join(@source, name)) }
|
|
29
|
+
|
|
30
|
+
paths = converter.run
|
|
31
|
+
|
|
32
|
+
assert_equal %w[chapter_0.xhtml chapter_12a.xhtml], paths.map { |path| File.basename(path) }.sort
|
|
33
|
+
assert_equal 'Cast List', heading('chapter_0.xhtml')
|
|
34
|
+
assert_equal 'Chapter 12a: Don’t Stop', heading('chapter_12a.xhtml')
|
|
35
|
+
assert_includes File.read(File.join(@output, 'chapter_12a.xhtml')), '<p>Tom & Jerry’s text.</p>'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_names_the_epilogue
|
|
39
|
+
FileUtils.touch(File.join(@source, 'Epilogue One Month.pdf'))
|
|
40
|
+
|
|
41
|
+
assert_equal(['chapter_epilogue.xhtml'], converter.run.map { |path| File.basename(path) })
|
|
42
|
+
assert_equal 'Epilogue: One Month', heading('chapter_epilogue.xhtml')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_rejects_pdfs_without_a_chapter_number
|
|
46
|
+
FileUtils.touch(File.join(@source, 'Afterword.pdf'))
|
|
47
|
+
|
|
48
|
+
error = assert_raises(ArgumentError) { converter.run }
|
|
49
|
+
assert_match(/Afterword\.pdf/, error.message)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_does_nothing_without_pdfs
|
|
53
|
+
assert_empty converter.run
|
|
54
|
+
refute_path_exists @output
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def converter
|
|
60
|
+
xml = XML
|
|
61
|
+
EpubTools::PDFConverter.new(source_dir: @source, output_dir: @output, book_title: 'Book').tap do |converter|
|
|
62
|
+
converter.define_singleton_method(:pdftohtml) { |_pdf| Nokogiri::XML(xml) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def heading(file)
|
|
67
|
+
Nokogiri::XML(File.read(File.join(@output, file))).at_xpath('//xmlns:h1').text
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_helper'
|
|
4
|
+
require_relative '../lib/epub_tools/pdf_layout'
|
|
5
|
+
|
|
6
|
+
class PDFLayoutTest < Minitest::Test
|
|
7
|
+
# Shaped like `pdftohtml -xml` output: body text at left=108, first-line indents at 135, lines 25px apart
|
|
8
|
+
# and paragraphs 37px apart, with full lines ending at 800.
|
|
9
|
+
XML = <<~XML
|
|
10
|
+
<pdf2xml>
|
|
11
|
+
<page number="1">
|
|
12
|
+
<fontspec id="0" size="42"/><fontspec id="1" size="18"/>
|
|
13
|
+
<text top="105" left="231" width="400" font="0">Chapter 1: Title in the PDF</text>
|
|
14
|
+
<text top="198" left="135" width="200" font="1"><i><b>Monday, 9:00 am</b></i></text>
|
|
15
|
+
<text top="231" left="135" width="600" font="1">The first paragraph has <i>italic words that </i></text>
|
|
16
|
+
<text top="256" left="108" width="692" font="1"><i>continue</i> on this line and ends with a half-</text>
|
|
17
|
+
<text top="281" left="108" width="692" font="1">dressed word and no trailing space</text>
|
|
18
|
+
<text top="306" left="108" width="600" font="1">before this line, which is full </text>
|
|
19
|
+
<text top="306" left="708" width="92" font="1">width </text>
|
|
20
|
+
</page>
|
|
21
|
+
<page number="2">
|
|
22
|
+
<text top="107" left="108" width="300" font="1">so it continues after the page break.</text>
|
|
23
|
+
<text top="144" left="108" width="400" font="1">A block paragraph after a gap.</text>
|
|
24
|
+
<text top="181" left="460" width="28" font="1">*** </text>
|
|
25
|
+
<text top="218" left="135" width="665" font="1">After the break, a paragraph whose writer hit</text>
|
|
26
|
+
<text top="255" left="135" width="200" font="1">enter by mistake.</text>
|
|
27
|
+
<text top="292" left="135" width="100" font="1">Short end.</text>
|
|
28
|
+
</page>
|
|
29
|
+
<page number="3">
|
|
30
|
+
<text top="107" left="108" width="400" font="1">A new paragraph, as the last page ended short.</text>
|
|
31
|
+
<text top="144" left="126" width="300" font="1">• A bullet that wraps</text>
|
|
32
|
+
<text top="164" left="138" width="200" font="1">onto a second line</text>
|
|
33
|
+
<text top="184" left="126" width="200" font="1">• Second bullet</text>
|
|
34
|
+
</page>
|
|
35
|
+
</pdf2xml>
|
|
36
|
+
XML
|
|
37
|
+
|
|
38
|
+
def test_rebuilds_paragraphs
|
|
39
|
+
html = EpubTools::PDFLayout.new(Nokogiri::XML(XML)).to_html
|
|
40
|
+
|
|
41
|
+
assert_equal [
|
|
42
|
+
'<p><i><b>Monday, 9:00 am</b></i></p>',
|
|
43
|
+
'<p>The first paragraph has <i>italic words that continue</i> on this line and ends with a half-dressed ' \
|
|
44
|
+
'word and no trailing space before this line, which is full width so it continues after the page break.</p>',
|
|
45
|
+
'<p>A block paragraph after a gap.</p>',
|
|
46
|
+
'<hr/>',
|
|
47
|
+
'<p>After the break, a paragraph whose writer hit enter by mistake.</p>',
|
|
48
|
+
'<p>Short end.</p>',
|
|
49
|
+
'<p>A new paragraph, as the last page ended short.</p>',
|
|
50
|
+
'<ul><li>A bullet that wraps onto a second line</li><li>Second bullet</li></ul>'
|
|
51
|
+
], html.split("\n ")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_empty_without_body_text
|
|
55
|
+
assert_predicate EpubTools::PDFLayout.new(Nokogiri::XML('<pdf2xml><page number="1"/></pdf2xml>')), :empty?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_helper'
|
|
4
|
+
require_relative '../lib/epub_tools/set_cover'
|
|
5
|
+
require_relative '../lib/epub_tools/epub_initializer'
|
|
6
|
+
require 'zip'
|
|
7
|
+
|
|
8
|
+
class SetCoverTest < Minitest::Test
|
|
9
|
+
DC = { 'dc' => 'http://purl.org/dc/elements/1.1/' }.freeze
|
|
10
|
+
|
|
11
|
+
def setup
|
|
12
|
+
@tmp = Dir.mktmpdir
|
|
13
|
+
@epub = File.join(@tmp, 'book.epub')
|
|
14
|
+
book = EpubTools::EpubInitializer.new(title: 'Book', author: 'Me', destination: File.join(@tmp, 'book')).run
|
|
15
|
+
EpubTools::PackEbook.new(input_dir: book, output_file: @epub).run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def teardown
|
|
19
|
+
FileUtils.rm_rf(@tmp)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_adds_then_replaces_the_cover_keeping_the_book_identity
|
|
23
|
+
identifier = book_identifier
|
|
24
|
+
apply_cover('art.png')
|
|
25
|
+
apply_cover('art.jpg')
|
|
26
|
+
|
|
27
|
+
assert_cover_entries('cover.jpg', 'image/jpeg')
|
|
28
|
+
assert_cover_files('cover.jpg', replaced: 'cover.png')
|
|
29
|
+
assert_equal identifier, book_identifier
|
|
30
|
+
assert_path_exists "#{@epub}.bak"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_rejects_unsupported_image_types
|
|
34
|
+
bmp = File.join(@tmp, 'art.bmp')
|
|
35
|
+
File.write(bmp, 'BMP')
|
|
36
|
+
|
|
37
|
+
error = assert_raises(ArgumentError) { EpubTools::SetCover.new(epub_file: @epub, cover_image: bmp) }
|
|
38
|
+
assert_match(/unsupported cover image type/, error.message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def apply_cover(name)
|
|
44
|
+
path = File.join(@tmp, name)
|
|
45
|
+
File.write(path, 'IMAGE')
|
|
46
|
+
EpubTools::SetCover.new(epub_file: @epub, cover_image: path).run
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def assert_cover_entries(href, media_type)
|
|
50
|
+
covers = opf.xpath('//xmlns:item[@properties="cover-image"]').map { |item| [item['href'], item['media-type']] }
|
|
51
|
+
spine = opf.xpath('//xmlns:itemref').map { |ref| ref['idref'] }
|
|
52
|
+
|
|
53
|
+
assert_equal [[href, media_type]], covers
|
|
54
|
+
assert_equal %w[cover-page title], spine
|
|
55
|
+
assert_equal 1, opf.xpath('//xmlns:meta[@name="cover"]').size
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def assert_cover_files(image, replaced:)
|
|
59
|
+
assert_includes zip_entry('OEBPS/cover.xhtml'), %(src="#{image}")
|
|
60
|
+
refute_includes Zip::File.open(@epub) { |zip| zip.map(&:name) }, "OEBPS/#{replaced}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def book_identifier = opf.at_xpath('//dc:identifier', DC).text
|
|
64
|
+
|
|
65
|
+
def opf = Nokogiri::XML(zip_entry('OEBPS/package.opf'))
|
|
66
|
+
|
|
67
|
+
def zip_entry(name) = Zip::File.open(@epub) { |zip| zip.read(name) }
|
|
68
|
+
end
|
data/test/split_chapters_test.rb
CHANGED
|
@@ -29,8 +29,7 @@ class SplitChaptersTest < Minitest::Test
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def test_run_generates_chapter_files
|
|
32
|
-
result = EpubTools::SplitChapters.new(input_file: @input, book_title: 'BookTitle', output_dir: @out
|
|
33
|
-
output_prefix: 'chap').run
|
|
32
|
+
result = EpubTools::SplitChapters.new(input_file: @input, book_title: 'BookTitle', output_dir: @out).run
|
|
34
33
|
|
|
35
34
|
verify_return_value(result)
|
|
36
35
|
verify_generated_files_exist(result)
|
|
@@ -46,9 +45,9 @@ class SplitChaptersTest < Minitest::Test
|
|
|
46
45
|
|
|
47
46
|
def verify_generated_files_exist(result)
|
|
48
47
|
expected_paths = [
|
|
49
|
-
File.join(@out, '
|
|
50
|
-
File.join(@out, '
|
|
51
|
-
File.join(@out, '
|
|
48
|
+
File.join(@out, 'chapter_0.xhtml'),
|
|
49
|
+
File.join(@out, 'chapter_1.xhtml'),
|
|
50
|
+
File.join(@out, 'chapter_2.xhtml')
|
|
52
51
|
]
|
|
53
52
|
|
|
54
53
|
expected_paths.each do |path|
|
|
@@ -58,7 +57,7 @@ class SplitChaptersTest < Minitest::Test
|
|
|
58
57
|
|
|
59
58
|
files = Dir.children(@out)
|
|
60
59
|
|
|
61
|
-
['
|
|
60
|
+
['chapter_0.xhtml', 'chapter_1.xhtml', 'chapter_2.xhtml'].each do |file|
|
|
62
61
|
assert_includes files, file
|
|
63
62
|
end
|
|
64
63
|
end
|
|
@@ -70,7 +69,7 @@ class SplitChaptersTest < Minitest::Test
|
|
|
70
69
|
end
|
|
71
70
|
|
|
72
71
|
def verify_prologue_content
|
|
73
|
-
prologue = File.read(File.join(@out, '
|
|
72
|
+
prologue = File.read(File.join(@out, 'chapter_0.xhtml'))
|
|
74
73
|
|
|
75
74
|
assert_includes prologue, '<h1>Prologue</h1>'
|
|
76
75
|
assert_includes prologue, 'Intro text'
|
|
@@ -78,7 +77,7 @@ class SplitChaptersTest < Minitest::Test
|
|
|
78
77
|
end
|
|
79
78
|
|
|
80
79
|
def verify_chapter_one_content
|
|
81
|
-
ch1 = File.read(File.join(@out, '
|
|
80
|
+
ch1 = File.read(File.join(@out, 'chapter_1.xhtml'))
|
|
82
81
|
|
|
83
82
|
assert_includes ch1, '<h1>Chapter 1</h1>'
|
|
84
83
|
assert_includes ch1, 'First paragraph'
|
|
@@ -86,7 +85,7 @@ class SplitChaptersTest < Minitest::Test
|
|
|
86
85
|
end
|
|
87
86
|
|
|
88
87
|
def verify_chapter_two_content
|
|
89
|
-
ch2 = File.read(File.join(@out, '
|
|
88
|
+
ch2 = File.read(File.join(@out, 'chapter_2.xhtml'))
|
|
90
89
|
|
|
91
90
|
assert_includes ch2, '<h1>Chapter 2</h1>'
|
|
92
91
|
assert_includes ch2, 'Second paragraph'
|
|
@@ -120,7 +119,7 @@ class SplitChaptersContinuedTest < Minitest::Test
|
|
|
120
119
|
|
|
121
120
|
def test_splits_continued_chapters
|
|
122
121
|
result = EpubTools::SplitChapters.new(
|
|
123
|
-
input_file: @input, book_title: 'Test', output_dir: @out
|
|
122
|
+
input_file: @input, book_title: 'Test', output_dir: @out
|
|
124
123
|
).run
|
|
125
124
|
|
|
126
125
|
assert_equal 3, result.size
|
|
@@ -131,7 +130,7 @@ class SplitChaptersContinuedTest < Minitest::Test
|
|
|
131
130
|
|
|
132
131
|
def test_continued_chapter_content
|
|
133
132
|
EpubTools::SplitChapters.new(
|
|
134
|
-
input_file: @input, book_title: 'Test', output_dir: @out
|
|
133
|
+
input_file: @input, book_title: 'Test', output_dir: @out
|
|
135
134
|
).run
|
|
136
135
|
|
|
137
136
|
continued = File.read(File.join(@out, 'chapter_1_5.xhtml'))
|
|
@@ -140,6 +139,13 @@ class SplitChaptersContinuedTest < Minitest::Test
|
|
|
140
139
|
assert_includes continued, 'Continued text'
|
|
141
140
|
end
|
|
142
141
|
|
|
142
|
+
def test_book_title_is_escaped
|
|
143
|
+
EpubTools::SplitChapters.new(input_file: @input, book_title: 'Tom & Jerry', output_dir: @out).run
|
|
144
|
+
chapter = Nokogiri::XML(File.read(File.join(@out, 'chapter_1.xhtml')), &:strict)
|
|
145
|
+
|
|
146
|
+
assert_equal 'Tom & Jerry - Chapter 1', chapter.at_xpath('//xmlns:title').text
|
|
147
|
+
end
|
|
148
|
+
|
|
143
149
|
def test_continued_marker_case_insensitive
|
|
144
150
|
content = <<~HTML
|
|
145
151
|
<?xml version="1.0"?>
|
|
@@ -155,7 +161,7 @@ class SplitChaptersContinuedTest < Minitest::Test
|
|
|
155
161
|
File.write(@input, content)
|
|
156
162
|
|
|
157
163
|
result = EpubTools::SplitChapters.new(
|
|
158
|
-
input_file: @input, book_title: 'Test', output_dir: @out
|
|
164
|
+
input_file: @input, book_title: 'Test', output_dir: @out
|
|
159
165
|
).run
|
|
160
166
|
|
|
161
167
|
assert_includes result, File.join(@out, 'chapter_5_5.xhtml')
|
data/test/style_finder_test.rb
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'yaml'
|
|
4
3
|
require_relative 'test_helper'
|
|
5
4
|
require_relative '../lib/epub_tools/style_finder'
|
|
6
5
|
|
|
7
|
-
class
|
|
6
|
+
class StyleFinderTest < Minitest::Test
|
|
8
7
|
def setup
|
|
9
8
|
@tmp = Dir.mktmpdir
|
|
10
9
|
@xhtml = File.join(@tmp, 'doc.xhtml')
|
|
@@ -16,7 +15,6 @@ class StyleFinder < Minitest::Test
|
|
|
16
15
|
</style></head><body></body></html>
|
|
17
16
|
HTML
|
|
18
17
|
File.write(@xhtml, content)
|
|
19
|
-
@yaml = File.join(@tmp, 'classes.yaml')
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def teardown
|
|
@@ -24,11 +22,7 @@ class StyleFinder < Minitest::Test
|
|
|
24
22
|
end
|
|
25
23
|
|
|
26
24
|
def test_finds_italic_and_bold_classes
|
|
27
|
-
EpubTools::StyleFinder.new(file_path: @xhtml
|
|
28
|
-
data = YAML.load_file(@yaml)
|
|
29
|
-
|
|
30
|
-
assert_equal ['c1'], data['italics']
|
|
31
|
-
assert_equal ['c2'], data['bolds']
|
|
25
|
+
assert_equal({ italics: ['c1'], bolds: ['c2'] }, EpubTools::StyleFinder.new(file_path: @xhtml).run)
|
|
32
26
|
end
|
|
33
27
|
|
|
34
28
|
def test_verbose_mode
|
|
@@ -37,7 +31,7 @@ class StyleFinder < Minitest::Test
|
|
|
37
31
|
Classes with font-weight: 700: c2
|
|
38
32
|
OUTPUT
|
|
39
33
|
assert_output(text) do
|
|
40
|
-
EpubTools::StyleFinder.new(file_path: @xhtml,
|
|
34
|
+
EpubTools::StyleFinder.new(file_path: @xhtml, verbose: true).run
|
|
41
35
|
end
|
|
42
36
|
end
|
|
43
37
|
end
|
data/test/test_helper.rb
CHANGED
data/test/unpack_ebook_test.rb
CHANGED
|
@@ -43,7 +43,7 @@ class UnpackEbookTest < Minitest::Test
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def add_remaining_files_to_zip(zip)
|
|
46
|
-
Dir.glob(File.join(@build_dir, '**', '*')
|
|
46
|
+
Dir.glob(File.join(@build_dir, '**', '*')).each do |src_path|
|
|
47
47
|
add_file_or_directory_to_zip(zip, src_path)
|
|
48
48
|
end
|
|
49
49
|
end
|
data/test/xhtml_cleaner_test.rb
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'yaml'
|
|
4
3
|
require_relative 'test_helper'
|
|
5
4
|
require_relative '../lib/epub_tools/xhtml_cleaner'
|
|
6
5
|
|
|
7
6
|
class XHTMLCleanerTest < Minitest::Test
|
|
8
7
|
def setup
|
|
9
8
|
@tmp = Dir.mktmpdir
|
|
10
|
-
@config = File.join(@tmp, 'config.yaml')
|
|
11
|
-
File.write(@config, { 'italics' => ['itclass'], 'bolds' => ['boldclass'] }.to_yaml)
|
|
12
9
|
@file = File.join(@tmp, 'test.xhtml')
|
|
13
10
|
content = <<~HTML
|
|
14
11
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -30,7 +27,7 @@ class XHTMLCleanerTest < Minitest::Test
|
|
|
30
27
|
end
|
|
31
28
|
|
|
32
29
|
def test_cleaner_removes_and_transforms_tags
|
|
33
|
-
result = EpubTools::XHTMLCleaner.new(filename: @file,
|
|
30
|
+
result = EpubTools::XHTMLCleaner.new(filename: @file, classes: { italics: ['itclass'], bolds: ['boldclass'] }).run
|
|
34
31
|
|
|
35
32
|
# Check return value is the filename that was cleaned
|
|
36
33
|
assert_equal @file, result
|