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
|
@@ -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
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'nokogiri'
|
|
5
|
-
require 'yaml'
|
|
6
4
|
require 'fileutils'
|
|
7
5
|
require_relative 'loggable'
|
|
8
6
|
require_relative 'style_finder'
|
|
9
7
|
require_relative 'xhtml_cleaner'
|
|
10
|
-
require_relative 'chapter_marker_detector'
|
|
11
8
|
|
|
12
9
|
module EpubTools
|
|
13
|
-
# Splits a multi-chapter XHTML file into individual chapter files.
|
|
10
|
+
# Splits a multi-chapter XHTML file into individual chapter files, named +chapter_N.xhtml+. A chapter starts at
|
|
11
|
+
# a "Chapter N" marker; "Chapter N (continued)" starts chapter N.5 and "Prologue" starts chapter 0.
|
|
14
12
|
class SplitChapters
|
|
15
13
|
include Loggable
|
|
16
14
|
|
|
15
|
+
# Tags that can contain chapter markers
|
|
16
|
+
MARKER_TAGS = %w[p span h2 h3 h4].freeze
|
|
17
|
+
# Tags that can contain prologue markers
|
|
18
|
+
PROLOGUE_TAGS = %w[h3 h4].freeze
|
|
19
|
+
|
|
17
20
|
def initialize(options = {})
|
|
18
21
|
@input_file = options.fetch(:input_file)
|
|
19
22
|
@book_title = options.fetch(:book_title)
|
|
20
23
|
@output_dir = options[:output_dir] || './chapters'
|
|
21
|
-
@output_prefix = options[:output_prefix] || 'chapter'
|
|
22
24
|
@verbose = options[:verbose] || false
|
|
23
|
-
@detector = ChapterMarkerDetector.new
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# Runs the splitter
|
|
@@ -28,8 +29,8 @@ module EpubTools
|
|
|
28
29
|
def run
|
|
29
30
|
FileUtils.mkdir_p(@output_dir)
|
|
30
31
|
doc = Nokogiri::HTML(read_and_strip_problematic_tags)
|
|
31
|
-
StyleFinder.new({ file_path: @input_file, verbose: @verbose }).run
|
|
32
|
-
extract_chapters(doc).map { |number, content| write_chapter_file(number, content) }
|
|
32
|
+
classes = StyleFinder.new({ file_path: @input_file, verbose: @verbose }).run
|
|
33
|
+
extract_chapters(doc).map { |number, content| write_chapter_file(number, content, classes) }
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
private
|
|
@@ -52,20 +53,21 @@ module EpubTools
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def process_node(node, chapters, current_number, current_fragment)
|
|
55
|
-
|
|
56
|
-
if
|
|
57
|
-
start_chapter(chapters,
|
|
56
|
+
number = marker_number(node)
|
|
57
|
+
if number
|
|
58
|
+
start_chapter(chapters, number, current_number, current_fragment)
|
|
58
59
|
else
|
|
59
60
|
current_fragment&.add_child(node.dup)
|
|
60
61
|
[current_number, current_fragment]
|
|
61
62
|
end
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
# @return [Numeric, nil] The chapter a marker node starts, or nil when the node is not a marker
|
|
66
|
+
def marker_number(node)
|
|
67
|
+
if MARKER_TAGS.include?(node.name) && (number = node.text[/Chapter\s+(\d+)/i, 1])
|
|
68
|
+
node.text.match?(/Chapter\s+\d+\s*\(continued\)/i) ? number.to_i + 0.5 : number.to_i
|
|
69
|
+
elsif PROLOGUE_TAGS.include?(node.name) && node.text.strip.match?(/\APrologue\z/i)
|
|
70
|
+
0
|
|
69
71
|
end
|
|
70
72
|
end
|
|
71
73
|
|
|
@@ -74,11 +76,11 @@ module EpubTools
|
|
|
74
76
|
[number, Nokogiri::HTML::DocumentFragment.parse('')]
|
|
75
77
|
end
|
|
76
78
|
|
|
77
|
-
def write_chapter_file(label, content)
|
|
79
|
+
def write_chapter_file(label, content, classes)
|
|
78
80
|
display = display_label(label)
|
|
79
|
-
filename = File.join(@output_dir, "#{
|
|
81
|
+
filename = File.join(@output_dir, "chapter_#{file_label(label)}.xhtml")
|
|
80
82
|
File.write(filename, build_xhtml_template(display, content))
|
|
81
|
-
XHTMLCleaner.new({ filename: filename }).run
|
|
83
|
+
XHTMLCleaner.new({ filename: filename, classes: classes }).run
|
|
82
84
|
log("Extracted: #{filename}")
|
|
83
85
|
filename
|
|
84
86
|
end
|
|
@@ -88,7 +90,7 @@ module EpubTools
|
|
|
88
90
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
89
91
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
|
90
92
|
<head>
|
|
91
|
-
<title>#{@book_title} - #{display_label}</title>
|
|
93
|
+
<title>#{@book_title.encode(xml: :text)} - #{display_label}</title>
|
|
92
94
|
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
93
95
|
</head>
|
|
94
96
|
<body>
|
|
@@ -1,69 +1,40 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'nokogiri'
|
|
5
|
-
require 'yaml'
|
|
6
4
|
require_relative 'loggable'
|
|
7
5
|
|
|
8
6
|
module EpubTools
|
|
9
7
|
# Finds css classes for bold and italic texts in Google Docs-generated EPUBs. Used by
|
|
10
|
-
# {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner]
|
|
11
|
-
#
|
|
8
|
+
# {SplitChapters}[rdoc-ref:EpubTools::SplitChapters] to tell {XHTMLCleaner}[rdoc-ref:EpubTools::XHTMLCleaner]
|
|
9
|
+
# which spans are bold or italic.
|
|
12
10
|
class StyleFinder
|
|
13
11
|
include Loggable
|
|
14
12
|
|
|
15
13
|
# Initializes the class
|
|
16
14
|
# @param options [Hash] Configuration options
|
|
17
15
|
# @option options [String] :file_path XHTML file to be analyzed (required)
|
|
18
|
-
# @option options [String] :output_path Path to write the YAML file (default: 'text_style_classes.yaml')
|
|
19
16
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
20
17
|
def initialize(options = {})
|
|
21
18
|
@file_path = options.fetch(:file_path)
|
|
22
|
-
@output_path = options[:output_path] || 'text_style_classes.yaml'
|
|
23
19
|
@verbose = options[:verbose] || false
|
|
24
20
|
raise ArgumentError, "File does not exist: #{@file_path}" unless File.exist?(@file_path)
|
|
25
21
|
end
|
|
26
22
|
|
|
27
23
|
# Runs the finder
|
|
28
|
-
# @return [Hash
|
|
24
|
+
# @return [Hash{Symbol => Array<String>}] The +:italics+ and +:bolds+ class names
|
|
29
25
|
def run
|
|
30
|
-
|
|
31
|
-
italics
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
doc = Nokogiri::HTML(File.read(@file_path))
|
|
37
|
-
doc.xpath('//style').map(&:text).join("\n")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def extract_style_classes(style_blocks)
|
|
41
|
-
italics = extract_classes(style_blocks, /font-style\s*:\s*italic/)
|
|
42
|
-
bolds = extract_classes(style_blocks, /font-weight\s*:\s*700/)
|
|
43
|
-
[italics, bolds]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def generate_output(italics, bolds)
|
|
47
|
-
print_summary(italics, bolds) if @verbose
|
|
48
|
-
|
|
49
|
-
data = { 'italics' => italics, 'bolds' => bolds }
|
|
50
|
-
File.write(@output_path, data.to_yaml)
|
|
51
|
-
data
|
|
26
|
+
styles = Nokogiri::HTML(File.read(@file_path)).xpath('//style').map(&:text).join("\n")
|
|
27
|
+
italics = classes_with(styles, /font-style\s*:\s*italic/)
|
|
28
|
+
bolds = classes_with(styles, /font-weight\s*:\s*700/)
|
|
29
|
+
log "Classes with font-style: italic: #{italics.join(', ')}" unless italics.empty?
|
|
30
|
+
log "Classes with font-weight: 700: #{bolds.join(', ')}" unless bolds.empty?
|
|
31
|
+
{ italics: italics, bolds: bolds }
|
|
52
32
|
end
|
|
53
33
|
|
|
54
34
|
private
|
|
55
35
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
style_text.scan(regex).flatten.uniq
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def print_summary(italics, bolds)
|
|
62
|
-
log "Classes with font-style: italic: #{italics.join(', ')}" unless italics.empty?
|
|
63
|
-
|
|
64
|
-
return if bolds.empty?
|
|
65
|
-
|
|
66
|
-
log "Classes with font-weight: 700: #{bolds.join(', ')}"
|
|
36
|
+
def classes_with(styles, pattern)
|
|
37
|
+
styles.scan(/\.([\w-]+)\s*{[^}]*#{pattern.source}[^}]*}/i).flatten.uniq
|
|
67
38
|
end
|
|
68
39
|
end
|
|
69
40
|
end
|
|
@@ -16,8 +16,7 @@ module EpubTools
|
|
|
16
16
|
# @option options [Boolean] :verbose Whether to print progress to STDOUT (default: false)
|
|
17
17
|
def initialize(options = {})
|
|
18
18
|
@epub_file = File.expand_path(options.fetch(:epub_file))
|
|
19
|
-
output_dir = options[:output_dir]
|
|
20
|
-
@output_dir = output_dir.nil? || output_dir.empty? ? default_dir : output_dir
|
|
19
|
+
@output_dir = options[:output_dir].to_s.empty? ? default_dir : options[:output_dir]
|
|
21
20
|
@verbose = options[:verbose] || false
|
|
22
21
|
end
|
|
23
22
|
|
|
@@ -26,30 +25,13 @@ module EpubTools
|
|
|
26
25
|
def run
|
|
27
26
|
validate!
|
|
28
27
|
FileUtils.mkdir_p(@output_dir)
|
|
29
|
-
|
|
28
|
+
Zip::File.extract_all(@epub_file, @output_dir) { true }
|
|
30
29
|
log "Unpacked #{File.basename(@epub_file)} to #{@output_dir}"
|
|
31
30
|
@output_dir
|
|
32
31
|
end
|
|
33
32
|
|
|
34
33
|
private
|
|
35
34
|
|
|
36
|
-
def extract_entries
|
|
37
|
-
Zip::File.open(@epub_file) do |zip|
|
|
38
|
-
zip.each do |entry|
|
|
39
|
-
extract_entry(entry)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def extract_entry(entry)
|
|
45
|
-
if entry.directory?
|
|
46
|
-
FileUtils.mkdir_p(File.join(@output_dir, entry.name))
|
|
47
|
-
else
|
|
48
|
-
FileUtils.mkdir_p(File.join(@output_dir, File.dirname(entry.name)))
|
|
49
|
-
entry.extract(destination_directory: @output_dir) { true }
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
35
|
def default_dir
|
|
54
36
|
[File.dirname(@epub_file), File.basename(@epub_file, '.epub')].join('/')
|
|
55
37
|
end
|
data/lib/epub_tools/version.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
require 'nokogiri'
|
|
5
|
-
require 'yaml'
|
|
6
4
|
|
|
7
5
|
module EpubTools
|
|
8
6
|
# Cleans Google Docs XHTMLs
|
|
@@ -12,7 +10,7 @@ module EpubTools
|
|
|
12
10
|
#
|
|
13
11
|
# - Removes any <tt><br /></tt> or <tt><hr /></tt> tags.
|
|
14
12
|
# - Removes empty <tt><p></tt> tags.
|
|
15
|
-
# - Using the +
|
|
13
|
+
# - Using the +classes+, it removes <tt><span></tt> tags that are used for bold or italics and
|
|
16
14
|
# replaces them with <tt><b></tt> or <tt><i></tt> tags.
|
|
17
15
|
# - Unwraps any <tt><span></tt> tags that have no classes assigned.
|
|
18
16
|
# - Outputs everything to a cleanly formatted +.xhtml+
|
|
@@ -20,12 +18,11 @@ module EpubTools
|
|
|
20
18
|
# Initializes the class
|
|
21
19
|
# @param options [Hash] Configuration options
|
|
22
20
|
# @option options [String] :filename The path to the xhtml to clean (required)
|
|
23
|
-
# @option options [String] :
|
|
24
|
-
#
|
|
21
|
+
# @option options [Hash{Symbol => Array<String>}] :classes The +:italics+ and +:bolds+ class names, as found by
|
|
22
|
+
# {StyleFinder}[rdoc-ref:EpubTools::StyleFinder] (required)
|
|
25
23
|
def initialize(options = {})
|
|
26
24
|
@filename = options.fetch(:filename)
|
|
27
|
-
|
|
28
|
-
@classes = YAML.load_file(class_config).transform_keys(&:to_sym)
|
|
25
|
+
@classes = options.fetch(:classes)
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
# Runs the cleaner
|
|
@@ -24,12 +24,7 @@ module EpubTools
|
|
|
24
24
|
# Runs the extraction process
|
|
25
25
|
# @return [Array<String>] Paths to all extracted XHTML files
|
|
26
26
|
def run
|
|
27
|
-
|
|
28
|
-
epub_files.each do |epub_path|
|
|
29
|
-
extracted = extract_xhtmls_from(epub_path)
|
|
30
|
-
all_extracted_files.concat(extracted) if extracted
|
|
31
|
-
end
|
|
32
|
-
all_extracted_files
|
|
27
|
+
epub_files.flat_map { |epub_path| extract_xhtmls_from(epub_path) || [] }
|
|
33
28
|
end
|
|
34
29
|
|
|
35
30
|
private
|
|
@@ -56,7 +51,6 @@ module EpubTools
|
|
|
56
51
|
|
|
57
52
|
renamed = "#{epub_name}_#{File.basename(entry.name)}"
|
|
58
53
|
output_path = File.join(@target_dir, renamed)
|
|
59
|
-
FileUtils.mkdir_p(File.dirname(output_path))
|
|
60
54
|
entry.extract(renamed, destination_directory: @target_dir) { true }
|
|
61
55
|
log output_path
|
|
62
56
|
extracted_files << output_path
|
data/lib/epub_tools.rb
CHANGED
|
@@ -5,14 +5,15 @@ require_relative 'epub_tools/loggable'
|
|
|
5
5
|
require_relative 'epub_tools/add_chapters'
|
|
6
6
|
require_relative 'epub_tools/epub_initializer'
|
|
7
7
|
require_relative 'epub_tools/split_chapters'
|
|
8
|
-
require_relative 'epub_tools/chapter_marker_detector'
|
|
9
8
|
require_relative 'epub_tools/xhtml_cleaner'
|
|
10
9
|
require_relative 'epub_tools/xhtml_extractor'
|
|
10
|
+
require_relative 'epub_tools/pdf_converter'
|
|
11
11
|
require_relative 'epub_tools/pack_ebook'
|
|
12
12
|
require_relative 'epub_tools/unpack_ebook'
|
|
13
13
|
require_relative 'epub_tools/book_builder'
|
|
14
14
|
require_relative 'epub_tools/compile_book'
|
|
15
15
|
require_relative 'epub_tools/append_book'
|
|
16
|
+
require_relative 'epub_tools/set_cover'
|
|
16
17
|
require_relative 'epub_tools/cli'
|
|
17
18
|
|
|
18
19
|
# Wrapper for all the other classes
|