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.
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 +20 -24
  6. data/Gemfile +5 -8
  7. data/Gemfile.lock +60 -54
  8. data/README.md +65 -25
  9. data/bin/epub-tools +1 -3
  10. data/epub_tools.gemspec +3 -4
  11. data/lib/epub_tools/add_chapters.rb +26 -14
  12. data/lib/epub_tools/append_book.rb +74 -0
  13. data/lib/epub_tools/book_builder.rb +114 -0
  14. data/lib/epub_tools/chapter_order.rb +33 -0
  15. data/lib/epub_tools/chapter_validator.rb +20 -7
  16. data/lib/epub_tools/cli.rb +121 -22
  17. data/lib/epub_tools/compile_book.rb +12 -126
  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 +35 -70
  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 +4 -0
  30. data/test/add_chapters_test.rb +134 -0
  31. data/test/append_book_test.rb +127 -0
  32. data/test/chapter_validator_test.rb +39 -0
  33. data/test/cli_commands_test.rb +9 -0
  34. data/test/cli_test.rb +40 -3
  35. data/test/compile_book_test.rb +15 -2
  36. data/test/epub_initializer_test.rb +18 -0
  37. data/test/pdf_converter_test.rb +69 -0
  38. data/test/pdf_layout_test.rb +57 -0
  39. data/test/set_cover_test.rb +68 -0
  40. data/test/split_chapters_test.rb +84 -9
  41. data/test/style_finder_test.rb +3 -9
  42. data/test/test_helper.rb +1 -1
  43. data/test/unpack_ebook_test.rb +1 -1
  44. data/test/xhtml_cleaner_test.rb +1 -4
  45. metadata +17 -34
  46. data/lib/epub_tools/cli/command_options_configurator.rb +0 -115
  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 -113
  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
@@ -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 'xhtml_generator'
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+ a basic style inherited from the repo
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
- @config = EpubConfiguration.new(options)
32
- @verbose = @config.verbose
33
- @xhtml_generator = create_xhtml_generator
34
- @metadata_builder = create_metadata_builder
35
- @file_writer = create_file_writer
36
- end
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
- @file_writer.create_structure
57
- @file_writer.write_mimetype
58
- write_title_page
59
- @file_writer.write_container
60
- write_cover if @config.cover_image_path
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 write_title_page
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
- ext = File.extname(@config.cover_image_path).downcase
80
- media_type = determine_media_type(ext)
81
- return unless media_type
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 copy_cover_image(_ext)
116
- dest = File.join(@config.destination, 'OEBPS', @config.cover_image_fname)
117
- FileUtils.cp(@config.cover_image_path, dest)
118
- end
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
- # Generates the package.opf with optional cover image entries
127
- def write_package_opf
128
- manifest_items, spine_items = @metadata_builder.build_manifest_and_spine
129
- metadata = @metadata_builder.build_metadata
130
- content = @metadata_builder.build_opf_xml(metadata, manifest_items, spine_items)
131
- @file_writer.write_package_opf(content)
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
- # Generates the initial navigation document (Table of Contents)
135
- def write_nav
136
- content = @xhtml_generator.build_nav_page
137
- @file_writer.write_xhtml('nav.xhtml', content)
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
- default_name = "#{File.basename(@input_dir)}.epub"
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
- # Add mimetype first and uncompressed
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