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
@@ -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
- marker = @detector.detect(node)
56
- if marker
57
- start_chapter(chapters, marker_number(marker, node), current_number, current_fragment)
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
- def marker_number(marker, node)
65
- case marker
66
- when :continued then @detector.extract_chapter_number(node) + 0.5
67
- when :chapter then @detector.extract_chapter_number(node)
68
- when :prologue then 0
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, "#{@output_prefix}_#{file_label(label)}.xhtml")
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] and
11
- # {SplitChapters}[rdoc-ref:EpubTools::SplitChapters].
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] Data containing the extracted style classes (italics and bolds)
24
+ # @return [Hash{Symbol => Array<String>}] The +:italics+ and +:bolds+ class names
29
25
  def run
30
- style_blocks = extract_style_blocks
31
- italics, bolds = extract_style_classes(style_blocks)
32
- generate_output(italics, bolds)
33
- end
34
-
35
- def extract_style_blocks
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 extract_classes(style_text, pattern)
57
- regex = /\.([\w-]+)\s*{[^}]*#{pattern.source}[^}]*}/i
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
- extract_entries
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module EpubTools
4
4
  # Ruby Gem version number
5
- VERSION = '0.6.0'
5
+ VERSION = '0.9.0'
6
6
  end
@@ -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 +class_config+, it removes <tt><span></tt> tags that are used for bold or italics and
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] :class_config Path to a YAML file containing the bold and italic classes to check
24
- # (default: 'text_style_classes.yaml')
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
- class_config = options[:class_config] || 'text_style_classes.yaml'
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
- all_extracted_files = []
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