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
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'time'
|
|
5
6
|
require_relative 'loggable'
|
|
6
|
-
require_relative '
|
|
7
|
-
require_relative 'epub_metadata_builder'
|
|
8
|
-
require_relative 'epub_file_writer'
|
|
9
|
-
require_relative 'epub_configuration'
|
|
7
|
+
require_relative 'set_cover'
|
|
10
8
|
|
|
11
9
|
module EpubTools
|
|
12
10
|
# Sets up a basic empty EPUB directory structure with the basic files created:
|
|
@@ -15,126 +13,122 @@ module EpubTools
|
|
|
15
13
|
# - +title.xhtml+ as a title page
|
|
16
14
|
# - +package.opf+
|
|
17
15
|
# - +nav.xhtml+ as a table of contents
|
|
18
|
-
# - +style.css+
|
|
16
|
+
# - +style.css+ the basic stylesheet that ships with the gem
|
|
19
17
|
# - cover image (optionally)
|
|
20
18
|
class EpubInitializer
|
|
21
19
|
include Loggable
|
|
22
20
|
|
|
21
|
+
STYLE = File.expand_path('../../style.css', __dir__)
|
|
22
|
+
|
|
23
|
+
CONTAINER = <<~XML
|
|
24
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
26
|
+
<rootfiles>
|
|
27
|
+
<rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
|
|
28
|
+
</rootfiles>
|
|
29
|
+
</container>
|
|
30
|
+
XML
|
|
31
|
+
|
|
32
|
+
NAV = <<~XHTML
|
|
33
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
34
|
+
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<title>Table of Contents</title>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<nav epub:type="toc" id="toc">
|
|
40
|
+
<h1>Table of Contents</h1>
|
|
41
|
+
<ol>
|
|
42
|
+
<li><a href="title.xhtml">Title Page</a></li>
|
|
43
|
+
</ol>
|
|
44
|
+
</nav>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
47
|
+
XHTML
|
|
48
|
+
|
|
23
49
|
# Initializes the class
|
|
24
50
|
# @param options [Hash] Configuration options
|
|
25
51
|
# @option options [String] :title Book title (required)
|
|
26
52
|
# @option options [String] :author Book author (required)
|
|
27
53
|
# @option options [String] :destination Target directory for the EPUB files (required)
|
|
28
|
-
# @option options [String] :cover_image Optional path to the cover image
|
|
54
|
+
# @option options [String] :cover_image Optional path to the cover image, added with {SetCover}[rdoc-ref:EpubTools::SetCover]
|
|
29
55
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
30
56
|
def initialize(options = {})
|
|
31
|
-
|
|
32
|
-
@
|
|
33
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
def create_xhtml_generator
|
|
41
|
-
XhtmlGenerator.new(title: @config.title, author: @config.author)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def create_metadata_builder
|
|
45
|
-
EpubMetadataBuilder.new(@config)
|
|
57
|
+
# Escaped once here, since they are only used inside the XML templates
|
|
58
|
+
@title = options.fetch(:title).encode(xml: :text)
|
|
59
|
+
@author = options.fetch(:author).encode(xml: :text)
|
|
60
|
+
@destination = File.expand_path(options.fetch(:destination))
|
|
61
|
+
@cover_image = options[:cover_image]
|
|
62
|
+
@verbose = options[:verbose] || false
|
|
46
63
|
end
|
|
47
64
|
|
|
48
|
-
def create_file_writer
|
|
49
|
-
EpubFileWriter.new(@config.destination)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
public
|
|
53
|
-
|
|
54
65
|
# Creates the empty ebook and returns the directory
|
|
55
66
|
def run
|
|
56
|
-
@
|
|
57
|
-
@
|
|
58
|
-
|
|
59
|
-
@
|
|
60
|
-
|
|
61
|
-
write_package_opf
|
|
62
|
-
write_nav
|
|
63
|
-
@file_writer.write_style
|
|
64
|
-
log "Created empty ebook structure at: #{@config.destination}"
|
|
65
|
-
@config.destination
|
|
67
|
+
FileUtils.mkdir_p([File.join(@destination, 'META-INF'), oebps_dir])
|
|
68
|
+
files.each { |path, content| File.write(File.join(@destination, path), content) }
|
|
69
|
+
write_cover if @cover_image
|
|
70
|
+
log "Created empty ebook structure at: #{@destination}"
|
|
71
|
+
@destination
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
private
|
|
69
75
|
|
|
70
|
-
def
|
|
71
|
-
content = @xhtml_generator.build_title_page
|
|
72
|
-
@file_writer.write_xhtml('title.xhtml', content)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Copies the cover image into the EPUB structure and creates a cover.xhtml page
|
|
76
|
-
def write_cover
|
|
77
|
-
return unless cover_image_exists?
|
|
76
|
+
def oebps_dir = File.join(@destination, 'OEBPS')
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
fname = "cover#{ext}"
|
|
84
|
-
@config.update_cover_info(fname, media_type)
|
|
85
|
-
@xhtml_generator.cover_image_fname = fname
|
|
86
|
-
update_metadata_builder_with_cover_info
|
|
87
|
-
|
|
88
|
-
copy_cover_image(ext)
|
|
89
|
-
write_cover_page
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def cover_image_exists?
|
|
93
|
-
return true if File.exist?(@config.cover_image_path)
|
|
94
|
-
|
|
95
|
-
warn "Warning: cover image '#{@config.cover_image_path}' not found; skipping cover support."
|
|
96
|
-
false
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def determine_media_type(ext)
|
|
100
|
-
case ext
|
|
101
|
-
when '.jpg', '.jpeg' then 'image/jpeg'
|
|
102
|
-
when '.png' then 'image/png'
|
|
103
|
-
when '.gif' then 'image/gif'
|
|
104
|
-
when '.svg' then 'image/svg+xml'
|
|
105
|
-
else
|
|
106
|
-
warn "Warning: unsupported cover image type '#{ext}'; skipping cover support."
|
|
107
|
-
nil
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def update_metadata_builder_with_cover_info
|
|
112
|
-
@metadata_builder = EpubMetadataBuilder.new(@config)
|
|
78
|
+
def files
|
|
79
|
+
{ 'mimetype' => 'application/epub+zip', 'META-INF/container.xml' => CONTAINER,
|
|
80
|
+
'OEBPS/title.xhtml' => title_page, 'OEBPS/package.opf' => package_opf, 'OEBPS/nav.xhtml' => NAV,
|
|
81
|
+
'OEBPS/style.css' => File.read(STYLE) }
|
|
113
82
|
end
|
|
114
83
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# Generates a cover.xhtml file displaying the cover image
|
|
121
|
-
def write_cover_page
|
|
122
|
-
content = @xhtml_generator.build_cover_page
|
|
123
|
-
@file_writer.write_xhtml('cover.xhtml', content)
|
|
84
|
+
def write_cover
|
|
85
|
+
SetCover.new(cover_image: @cover_image).apply_to(oebps_dir)
|
|
86
|
+
rescue ArgumentError => e
|
|
87
|
+
warn "Warning: #{e.message}; skipping cover support."
|
|
124
88
|
end
|
|
125
89
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
90
|
+
def title_page
|
|
91
|
+
<<~XHTML
|
|
92
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
93
|
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="UTF-8" />
|
|
96
|
+
<title>#{@title}</title>
|
|
97
|
+
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
<h1 class="title">#{@title}</h1>
|
|
101
|
+
<p class="author">by #{@author}</p>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|
|
104
|
+
XHTML
|
|
132
105
|
end
|
|
133
106
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
107
|
+
def package_opf
|
|
108
|
+
<<~XML
|
|
109
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
110
|
+
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="en">
|
|
111
|
+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
112
|
+
<dc:identifier id="pub-id">urn:uuid:#{SecureRandom.uuid}</dc:identifier>
|
|
113
|
+
<dc:title>#{@title}</dc:title>
|
|
114
|
+
<dc:creator>#{@author}</dc:creator>
|
|
115
|
+
<dc:language>en</dc:language>
|
|
116
|
+
<meta property="dcterms:modified">#{Time.now.utc.iso8601}</meta>
|
|
117
|
+
<meta property="schema:accessMode">textual</meta>
|
|
118
|
+
<meta property="schema:accessibilityFeature">unknown</meta>
|
|
119
|
+
<meta property="schema:accessibilityHazard">none</meta>
|
|
120
|
+
<meta property="schema:accessModeSufficient">textual</meta>
|
|
121
|
+
</metadata>
|
|
122
|
+
<manifest>
|
|
123
|
+
<item id="style" href="style.css" media-type="text/css"/>
|
|
124
|
+
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
|
125
|
+
<item id="title" href="title.xhtml" media-type="application/xhtml+xml"/>
|
|
126
|
+
</manifest>
|
|
127
|
+
<spine>
|
|
128
|
+
<itemref idref="title"/>
|
|
129
|
+
</spine>
|
|
130
|
+
</package>
|
|
131
|
+
XML
|
|
138
132
|
end
|
|
139
133
|
end
|
|
140
134
|
end
|
|
@@ -17,13 +17,7 @@ module EpubTools
|
|
|
17
17
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
18
18
|
def initialize(options = {})
|
|
19
19
|
@input_dir = File.expand_path(options.fetch(:input_dir))
|
|
20
|
-
|
|
21
|
-
output_file = options[:output_file]
|
|
22
|
-
@output_file = if output_file.nil? || output_file.empty?
|
|
23
|
-
default_name
|
|
24
|
-
else
|
|
25
|
-
output_file
|
|
26
|
-
end
|
|
20
|
+
@output_file = options[:output_file].to_s.empty? ? "#{File.basename(@input_dir)}.epub" : options[:output_file]
|
|
27
21
|
@verbose = options[:verbose] || false
|
|
28
22
|
end
|
|
29
23
|
|
|
@@ -48,8 +42,7 @@ module EpubTools
|
|
|
48
42
|
|
|
49
43
|
def create_zip_file(target)
|
|
50
44
|
Zip::File.open(target, create: true) do |zip|
|
|
51
|
-
#
|
|
52
|
-
add_mimetype(zip)
|
|
45
|
+
zip.add_stored('mimetype', 'mimetype') # EPUB readers need it first and uncompressed
|
|
53
46
|
add_content_files(zip)
|
|
54
47
|
end
|
|
55
48
|
end
|
|
@@ -72,10 +65,5 @@ module EpubTools
|
|
|
72
65
|
|
|
73
66
|
raise ArgumentError, "Error: 'mimetype' file missing in #{@input_dir}"
|
|
74
67
|
end
|
|
75
|
-
|
|
76
|
-
def add_mimetype(zip)
|
|
77
|
-
# Add mimetype first and uncompressed (Stored)
|
|
78
|
-
zip.add_stored('mimetype', 'mimetype')
|
|
79
|
-
end
|
|
80
68
|
end
|
|
81
69
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative 'loggable'
|
|
7
|
+
require_relative 'pdf_layout'
|
|
8
|
+
|
|
9
|
+
module EpubTools
|
|
10
|
+
# Converts chapter PDFs into chapter XHTML files that {AddChapters}[rdoc-ref:EpubTools::AddChapters] can add
|
|
11
|
+
# to an EPUB. Text is read with poppler's +pdftohtml+, which keeps italics and line positions; see
|
|
12
|
+
# {PDFLayout}[rdoc-ref:EpubTools::PDFLayout] for how paragraphs are rebuilt.
|
|
13
|
+
#
|
|
14
|
+
# PDFs must be named <tt>"<number><optional letters> <Title>.pdf"</tt>: <tt>13a I Hear You.pdf</tt> becomes
|
|
15
|
+
# +chapter_13a.xhtml+, headed "Chapter 13a: I Hear You". Chapter 0 is front matter, headed with just its title.
|
|
16
|
+
# <tt>Epilogue One Month.pdf</tt> becomes +chapter_epilogue.xhtml+, headed "Epilogue: One Month".
|
|
17
|
+
class PDFConverter
|
|
18
|
+
include Loggable
|
|
19
|
+
|
|
20
|
+
FILENAME = /\A(?:(?<number>\d+)(?<suffix>[a-z]*)|(?<epilogue>Epilogue)) +(?<title>.+)\z/
|
|
21
|
+
|
|
22
|
+
# Initializes the class
|
|
23
|
+
# @param options [Hash] Configuration options
|
|
24
|
+
# @option options [String] :source_dir Directory containing the chapter PDFs (required)
|
|
25
|
+
# @option options [String] :book_title Book title for the XHTML <tt><title></tt> tags (required)
|
|
26
|
+
# @option options [String] :output_dir Where to write the chapter files (default: './chapters')
|
|
27
|
+
# @option options [Boolean] :verbose Whether to log progress to STDOUT (default: false)
|
|
28
|
+
def initialize(options = {})
|
|
29
|
+
@source_dir = File.expand_path(options.fetch(:source_dir))
|
|
30
|
+
@book_title = options.fetch(:book_title)
|
|
31
|
+
@output_dir = File.expand_path(options[:output_dir] || './chapters')
|
|
32
|
+
@verbose = options[:verbose] || false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs the converter
|
|
36
|
+
# @return [Array<String>] Paths of the generated chapter files (empty when there are no PDFs)
|
|
37
|
+
def run
|
|
38
|
+
pdfs = Dir.glob(File.join(@source_dir, '*.pdf'))
|
|
39
|
+
validate_names!(pdfs)
|
|
40
|
+
FileUtils.mkdir_p(@output_dir) unless pdfs.empty?
|
|
41
|
+
pdfs.map { |pdf| convert(pdf) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_name(pdf) = File.basename(pdf, '.pdf').unicode_normalize(:nfc).match(FILENAME)
|
|
47
|
+
|
|
48
|
+
def validate_names!(pdfs)
|
|
49
|
+
bad = pdfs.reject { |pdf| parse_name(pdf) }.map { |pdf| File.basename(pdf) }
|
|
50
|
+
return if bad.empty?
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, "PDF names must look like '12a Chapter Title.pdf': #{bad.join(', ')}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def convert(pdf)
|
|
56
|
+
number, title = chapter_name(pdf)
|
|
57
|
+
label = case number
|
|
58
|
+
when '0' then title
|
|
59
|
+
when 'epilogue' then "Epilogue: #{title}"
|
|
60
|
+
else "Chapter #{number}: #{title}"
|
|
61
|
+
end
|
|
62
|
+
path = File.join(@output_dir, "chapter_#{number}.xhtml")
|
|
63
|
+
File.write(path, well_formed(build_xhtml(label.encode(xml: :text), chapter_body(pdf))))
|
|
64
|
+
log "Converted: #{File.basename(pdf)} -> #{File.basename(path)}"
|
|
65
|
+
path
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Array(String, String)] The chapter number without leading zeros (e.g. "13a", or "epilogue") and its
|
|
69
|
+
# title
|
|
70
|
+
def chapter_name(pdf)
|
|
71
|
+
name = parse_name(pdf)
|
|
72
|
+
number = name[:epilogue] ? 'epilogue' : "#{name[:number].to_i}#{name[:suffix]}"
|
|
73
|
+
[number, name[:title].tr("'", '’')]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def chapter_body(pdf)
|
|
77
|
+
layout = PDFLayout.new(pdftohtml(pdf))
|
|
78
|
+
raise "No text found in '#{pdf}' (is it a scanned image?)" if layout.empty?
|
|
79
|
+
|
|
80
|
+
layout.to_html
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def pdftohtml(pdf)
|
|
84
|
+
xml, status = Open3.capture2('pdftohtml', '-xml', '-i', '-q', '-stdout', pdf)
|
|
85
|
+
raise "pdftohtml failed to read '#{pdf}'" unless status.success?
|
|
86
|
+
|
|
87
|
+
Nokogiri::XML(xml)
|
|
88
|
+
rescue Errno::ENOENT
|
|
89
|
+
raise 'pdftohtml not found; install poppler (e.g. `brew install poppler`) to convert PDFs'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def well_formed(xhtml)
|
|
93
|
+
Nokogiri::XML(xhtml, &:strict)
|
|
94
|
+
xhtml
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_xhtml(label, body)
|
|
98
|
+
<<~HTML
|
|
99
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
100
|
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
101
|
+
<head>
|
|
102
|
+
<title>#{@book_title.encode(xml: :text)} - #{label}</title>
|
|
103
|
+
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
104
|
+
</head>
|
|
105
|
+
<body>
|
|
106
|
+
<h1>#{label}</h1>
|
|
107
|
+
#{body}
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
HTML
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module EpubTools
|
|
6
|
+
# Rebuilds a chapter's paragraphs from +pdftohtml -xml+ output as XHTML body markup. Used by
|
|
7
|
+
# {PDFConverter}[rdoc-ref:EpubTools::PDFConverter].
|
|
8
|
+
#
|
|
9
|
+
# - The PDF's own title (lines larger than body text at the top of page 1) is dropped.
|
|
10
|
+
# - Paragraphs are rebuilt from first-line indents and vertical gaps, including across page breaks.
|
|
11
|
+
# - Lines made only of symbols (<tt>***</tt>, <tt>____</tt>) become scene breaks (<tt><hr/></tt>).
|
|
12
|
+
# - Lines starting with a bullet become list items.
|
|
13
|
+
# - Italics and bold are kept; ligatures (<tt>fi</tt>, <tt>fl</tt>) become plain letters.
|
|
14
|
+
class PDFLayout
|
|
15
|
+
SCENE_BREAK = /\A[*_#~=\s]+\z/
|
|
16
|
+
BULLET = '•'
|
|
17
|
+
RAW_XML = Nokogiri::XML::Node::SaveOptions::AS_XML
|
|
18
|
+
# One line of text: the pdftohtml <text> chunks sharing a +top+ (it splits lines wherever the font changes)
|
|
19
|
+
Line = Struct.new(:page, :chunks, :font_size) do
|
|
20
|
+
def top = chunks.first['top'].to_i
|
|
21
|
+
def left = chunks.first['left'].to_i
|
|
22
|
+
def right = chunks.last['left'].to_i + chunks.last['width'].to_i
|
|
23
|
+
def text = chunks.map(&:text).join
|
|
24
|
+
def html = chunks.map { |chunk| chunk.children.to_xml(save_with: RAW_XML, encoding: 'UTF-8') }.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param doc [Nokogiri::XML::Document] Output of <tt>pdftohtml -xml</tt>
|
|
28
|
+
def initialize(doc)
|
|
29
|
+
@lines = without_title(parse_lines(doc))
|
|
30
|
+
@margin = @lines.map(&:left).min
|
|
31
|
+
@text_edge = @lines.map(&:right).sort[@lines.size * 9 / 10]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] Whether the PDF has no body text
|
|
35
|
+
def empty? = @lines.empty?
|
|
36
|
+
|
|
37
|
+
# @return [String] XHTML for the chapter body
|
|
38
|
+
def to_html
|
|
39
|
+
paragraphs.chunk_while { |a, b| bullet?(a) && bullet?(b) }.map { |group| render(group) }.join("\n ")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse_lines(doc)
|
|
45
|
+
sizes = doc.css('fontspec').to_h { |font| [font['id'], font['size'].to_i] }
|
|
46
|
+
doc.css('page').flat_map { |page| page_lines(page, sizes) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def page_lines(page, sizes)
|
|
50
|
+
page.css('text').group_by { |chunk| chunk['top'].to_i }.sort.filter_map do |_top, chunks|
|
|
51
|
+
chunks = visible_in_order(chunks)
|
|
52
|
+
Line.new(page['number'].to_i, chunks, chunks.map { |chunk| sizes[chunk['font']] }.max) unless chunks.empty?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A line's chunks left to right, minus leading whitespace-only ones
|
|
57
|
+
def visible_in_order(chunks)
|
|
58
|
+
chunks.sort_by { |chunk| chunk['left'].to_i }.drop_while { |chunk| chunk.text.strip.empty? }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def without_title(lines)
|
|
62
|
+
body_size = lines.map(&:font_size).tally.max_by(&:last)&.first
|
|
63
|
+
lines.drop_while { |line| line.page == 1 && line.font_size > body_size * 1.4 }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Array<Array<Line>, :break>] Lines grouped into paragraphs, with scene breaks as +:break+
|
|
67
|
+
def paragraphs
|
|
68
|
+
@lines.each_with_object([]) do |line, paras|
|
|
69
|
+
if line.text.match?(SCENE_BREAK)
|
|
70
|
+
paras << :break
|
|
71
|
+
elsif paras.last.is_a?(Array) && continues?(paras.last, line)
|
|
72
|
+
paras.last << line
|
|
73
|
+
else
|
|
74
|
+
paras << [line]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Distances are relative to font size: lines are ~1.2-1.45x the size apart, paragraphs ~1.9x or more.
|
|
80
|
+
def continues?(para, line)
|
|
81
|
+
return false if line.text.start_with?(BULLET)
|
|
82
|
+
return line.left > para.first.left if bullet?(para) # wrapped bullet lines hang past the bullet
|
|
83
|
+
return true if line.text.match?(/\A\p{Ll}/) # a stray paragraph break mid-sentence
|
|
84
|
+
return false if line.left > @margin + 5 # first-line indent
|
|
85
|
+
|
|
86
|
+
flows_on?(para.last, line)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Across a page break, only a full previous line means the paragraph carries on (text is ragged-right).
|
|
90
|
+
# ponytail: misses paragraph ends whose last line is nearly full width, unless the next one is indented
|
|
91
|
+
def flows_on?(prev, line)
|
|
92
|
+
return prev.right > @text_edge - (prev.font_size * 6) if line.page != prev.page
|
|
93
|
+
|
|
94
|
+
line.top - prev.top < prev.font_size * 1.7
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bullet?(para) = para.is_a?(Array) && para.first.text.start_with?(BULLET)
|
|
98
|
+
|
|
99
|
+
def render(group)
|
|
100
|
+
return '<hr/>' if group.first == :break
|
|
101
|
+
return "<p>#{inline_html(group.first)}</p>" unless bullet?(group.first)
|
|
102
|
+
|
|
103
|
+
"<ul>#{group.map { |item| "<li>#{inline_html(item).delete_prefix(BULLET).lstrip}</li>" }.join}</ul>"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Joins a paragraph's lines. Wrapped lines usually keep their trailing space; add one when they don't,
|
|
107
|
+
# unless the line ends in a hyphen or dash ("half-" + "dressed").
|
|
108
|
+
def inline_html(para)
|
|
109
|
+
html = para.first.html.dup
|
|
110
|
+
para.each_cons(2) { |prev, line| html << (prev.text.match?(/[\s\-–—]\z/) ? '' : ' ') << line.html }
|
|
111
|
+
html.gsub(/[ff-st]/) { |ligature| ligature.unicode_normalize(:nfkc) }
|
|
112
|
+
.gsub(%r{</b></i>(\s*)<i><b>}, '\1').gsub(%r{</(i|b)>(\s*)<\1>}, '\2') # italics split across lines
|
|
113
|
+
.gsub(/ {2,}/, ' ').strip
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require_relative 'loggable'
|
|
7
|
+
require_relative 'unpack_ebook'
|
|
8
|
+
require_relative 'pack_ebook'
|
|
9
|
+
|
|
10
|
+
module EpubTools
|
|
11
|
+
# Adds a cover image to an EPUB, replacing any cover it already has: the image, a +cover.xhtml+ page first in
|
|
12
|
+
# the reading order, and the manifest and metadata entries marking it as the cover.
|
|
13
|
+
#
|
|
14
|
+
# {#run} updates a packed +.epub+ in place (backing it up to <tt><epub>.bak</tt> first). The book keeps its
|
|
15
|
+
# identifier, so reading apps still treat it as the same book. {#apply_to} updates an unpacked EPUB instead;
|
|
16
|
+
# {EpubInitializer}[rdoc-ref:EpubTools::EpubInitializer] uses it for new books.
|
|
17
|
+
class SetCover
|
|
18
|
+
include Loggable
|
|
19
|
+
|
|
20
|
+
# Media types of the supported cover image extensions
|
|
21
|
+
MEDIA_TYPES = {
|
|
22
|
+
'.jpg' => 'image/jpeg', '.jpeg' => 'image/jpeg', '.png' => 'image/png', '.gif' => 'image/gif',
|
|
23
|
+
'.svg' => 'image/svg+xml'
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Initializes the class
|
|
27
|
+
# @param options [Hash] Configuration options
|
|
28
|
+
# @option options [String] :cover_image Path to the cover image: jpg, png, gif or svg (required)
|
|
29
|
+
# @option options [String] :epub_file The EPUB to add the cover to (required for {#run})
|
|
30
|
+
# @option options [Boolean] :verbose Whether to log progress to STDOUT (default: false)
|
|
31
|
+
# @raise [ArgumentError] if a file is missing or the image type is unsupported
|
|
32
|
+
def initialize(options = {})
|
|
33
|
+
@cover_image = File.expand_path(options.fetch(:cover_image))
|
|
34
|
+
@extension = File.extname(@cover_image).downcase
|
|
35
|
+
@epub_file = options[:epub_file] && File.expand_path(options[:epub_file])
|
|
36
|
+
@verbose = options[:verbose] || false
|
|
37
|
+
validate!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Adds the cover to +epub_file+
|
|
41
|
+
# @return [String] Path to the updated EPUB
|
|
42
|
+
def run
|
|
43
|
+
raise ArgumentError, 'An :epub_file is required to run' unless @epub_file
|
|
44
|
+
|
|
45
|
+
FileUtils.cp(@epub_file, "#{@epub_file}.bak")
|
|
46
|
+
Dir.mktmpdir do |dir|
|
|
47
|
+
UnpackEbook.new(epub_file: @epub_file, output_dir: dir).run
|
|
48
|
+
apply_to(File.join(dir, 'OEBPS'))
|
|
49
|
+
PackEbook.new(input_dir: dir, output_file: @epub_file).run
|
|
50
|
+
end
|
|
51
|
+
log "Set the cover of #{@epub_file} to #{File.basename(@cover_image)} (backup: #{@epub_file}.bak)"
|
|
52
|
+
@epub_file
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Adds the cover to an unpacked EPUB
|
|
56
|
+
# @param oebps_dir [String] Directory containing the EPUB's +package.opf+
|
|
57
|
+
def apply_to(oebps_dir)
|
|
58
|
+
opf_path = File.join(oebps_dir, 'package.opf')
|
|
59
|
+
opf = Nokogiri::XML(File.read(opf_path)) { |config| config.default_xml.noblanks }
|
|
60
|
+
remove_old_cover(opf, oebps_dir)
|
|
61
|
+
FileUtils.cp(@cover_image, File.join(oebps_dir, image_name))
|
|
62
|
+
File.write(File.join(oebps_dir, 'cover.xhtml'), cover_page)
|
|
63
|
+
add_cover(opf)
|
|
64
|
+
File.write(opf_path, opf.to_xml(indent: 2))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def validate!
|
|
70
|
+
raise ArgumentError, "EPUB '#{@epub_file}' not found" if @epub_file && !File.file?(@epub_file)
|
|
71
|
+
raise ArgumentError, "cover image '#{@cover_image}' not found" unless File.file?(@cover_image)
|
|
72
|
+
return if MEDIA_TYPES.key?(@extension)
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "unsupported cover image type '#{@extension}' (use jpg, png, gif or svg)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def image_name = "cover#{@extension}"
|
|
78
|
+
|
|
79
|
+
def cover_page
|
|
80
|
+
<<~XHTML
|
|
81
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
82
|
+
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
83
|
+
<head>
|
|
84
|
+
<meta charset="UTF-8" />
|
|
85
|
+
<title>Cover</title>
|
|
86
|
+
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<div class="cover-image">
|
|
90
|
+
<img src="#{image_name}" alt="Cover"/>
|
|
91
|
+
</div>
|
|
92
|
+
</body>
|
|
93
|
+
</html>
|
|
94
|
+
XHTML
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def remove_old_cover(opf, oebps_dir)
|
|
98
|
+
opf.xpath('//xmlns:item[@id="cover-image" or contains(@properties, "cover-image")]').each do |item|
|
|
99
|
+
FileUtils.rm_f(File.join(oebps_dir, item['href']))
|
|
100
|
+
item.remove
|
|
101
|
+
end
|
|
102
|
+
opf.xpath('//xmlns:meta[@name="cover"] | //xmlns:item[@id="cover-page"] | //xmlns:itemref[@idref="cover-page"]')
|
|
103
|
+
.each(&:remove)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def add_cover(opf)
|
|
107
|
+
opf.at_xpath('//xmlns:metadata').add_child(opf.create_element('meta', name: 'cover', content: 'cover-image'))
|
|
108
|
+
manifest = opf.at_xpath('//xmlns:manifest')
|
|
109
|
+
manifest.add_child(opf.create_element('item', id: 'cover-image', href: image_name,
|
|
110
|
+
'media-type': MEDIA_TYPES[@extension], properties: 'cover-image'))
|
|
111
|
+
manifest.add_child(opf.create_element('item', id: 'cover-page', href: 'cover.xhtml',
|
|
112
|
+
'media-type': 'application/xhtml+xml'))
|
|
113
|
+
opf.at_xpath('//xmlns:spine').prepend_child(opf.create_element('itemref', idref: 'cover-page'))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|