bindery 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ *_gen
6
+ *.epub
7
+ rspec.txt
8
+ coverage
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "http://rubygems.org"
2
+ gemspec
@@ -0,0 +1,126 @@
1
+ # Bindery
2
+
3
+ [Bindery][] is a [Ruby][] library for packaging ebooks.
4
+
5
+ Electronic book formats are typically rather simple.
6
+ An EPUB book, for example, is just HTML, CSS, and some image files packed into a single zip file along with various bits of metadata.
7
+ But there are numerous tricky details, and a lot of redundancy in the metadata.
8
+ Bindery aims to simplify the process.
9
+
10
+ To use Bindery, you write a simple Ruby program that describes the book's structure and important metadata.
11
+ This program is also responsible for identifying the HTML files that correspond to the book's chapters; those files might already exist, but the program can generate them on the fly as well.
12
+ Then, you simply run that program to generate the book.
13
+
14
+ Initially, Bindery will support generating the [EPUB][] format.
15
+ I'll work to add support for other formats when EPUB support is working well.
16
+
17
+ ## Example
18
+
19
+ Here is a quick example of a Bindery program.
20
+
21
+ ```ruby
22
+ require 'bindery'
23
+ require 'active_support'
24
+ require 'maruku'
25
+
26
+ Bindery.book do |b|
27
+ b.output 'anthology'
28
+ b.format :epub
29
+
30
+ b.title "The Great Elbonian Novel"
31
+ b.language 'en'
32
+ b.url 'http://glv.github.com/bindery/books/example'
33
+ b.author 'Glenn Vanderburg'
34
+
35
+ b.frontmatter 'Preface', 'pref.xhtml'
36
+
37
+ b.chapter 'Introduction', 'intro.xhtml'
38
+
39
+ # process a collection of files called "chapter_1.md"
40
+ Dir['*.md'].sort.each do |file_name|
41
+ stem = file_name.sub(/\.md$/, '')
42
+ output_file_name = "#{stem}.xhtml_gen"
43
+ system %{markdown <#{file_name} >#{output_file_name}}
44
+ b.chapter stem.humanize, output_file_name
45
+ end
46
+
47
+ # an alternative way to process chapters, assuming the chapters use
48
+ # Maruku's metadata support and contain the chapter title. (Maruku
49
+ # is a Ruby Markdown library that supports numerous common extensions
50
+ # to the basic Markdown syntax.)
51
+ Dir['*.maruku'].sort.each do |file_name|
52
+ b.chapter do
53
+ output_file_name = file_name.sub(/\.maruku$/, '.xhtml_gen')
54
+ doc = Maruku.new(IO.read(file_name))
55
+ open(output_file_name, "w") {|os| os.write(doc.to_html)}
56
+ # return a hash with title and file info:
57
+ { :title => doc.attributes[:title], :file => output_file_name }
58
+ end
59
+ end
60
+
61
+ b.backmatter 'Colophon' # filename assumed to be colophon.xhtml
62
+ end
63
+ ```
64
+
65
+ ## Status
66
+
67
+ Bindery is currently limited to generating EPUB books that do not contain images or other non-textual content.
68
+
69
+ It is also in a very early stage.
70
+ It's capable of generating very simple books, but many features (including the frontmatter and backmatter methods in the example above) do not work yet.
71
+ Additionally, validation is sketchy, so it's quite likely that the books you build using Bindery will not be valid EPUB files.
72
+ But the basics are there, and contributions are welcome.
73
+
74
+ Planned features include:
75
+
76
+ * options for sections (with and without section title pages) rather than just a flat chapter structure.
77
+ * additional metadata including all of [Dublin Core][].
78
+ * support for images
79
+ * support for multiple stylesheets
80
+ * support for generating Mobipocket books
81
+
82
+ ## When to use Bindery
83
+
84
+ There are other systems for generating ebooks; [git-scribe][] is one of the best.
85
+ However most ebook generation systems prescribe a particular authoring format or tool.
86
+ For example, git-scribe assumes you will be using [AsciiDoc][] and [Git][], and is additionally optimized for use on [GitHub][].
87
+ Other systems are designed to work with proprietary tools such as [Microsoft Word][] or [Adobe InDesign][].
88
+ Each such format or tool has strengths and weaknesses.
89
+ For example, while AsciiDoc is an excellent tool for writing technical books, it's less well suited for novels, memoirs, or poetry.
90
+ For such tasks (and depending on your personal preferences) it may be better to write in [Markdown][] or [Textile][].
91
+
92
+ More importantly, requiring a specific format makes an ebook system difficult to use for a book that includes existing material being republished or repurposed.
93
+
94
+ Imagine that, like [Joel Spolsky][], you want to publish an anthology of [your best blog posts][joel on software], or a collection of the [best software writing from around the web][best software writing].
95
+ Or, like [Steven Johnson][], you write carefully researched books that weave together threads from many fields and disciplines, so you write and collect research in a sophisticated research management tool like [DEVONthink][] (Johnson has written [two][johnson dt1] [articles][johnson dt2] describing his research and writing methods).
96
+ Or perhaps you simply want to read through the [SproutCore Guides][], but your spare time is fragmented (and usually occurs when you're on an airplane) so you want to convert them to an ebook format so they're always available and your ebook reader will keep track of how far you've read.
97
+ (For that matter, if you're part of an effort like [SproutCore][] and have great online material like that, Bindery might be the easiest way to package it up into a handy offline format.)
98
+
99
+ In any of those cases, the writing will exist in a format that probably doesn't match what existing ebook generation systems expect, and in some cases it will already be in the HTML format that all popular ebook formats are based on.
100
+
101
+ Bindery is designed to support those use cases, not just brand new books written with a particular toolchain in mind.
102
+ If you want to publish in both electronic and paper formats, Bindery is probably not for you.
103
+ Bindery is targeted at electronic books only.
104
+
105
+ [adobe indesign]: http://www.adobe.com/products/indesign.html
106
+ [asciidoc]: http://www.methods.co.nz/asciidoc/
107
+ [best software writing]: http://www.apress.com/9781590595008
108
+ [Bindery]: http://github.com/glv/bindery
109
+ [devonthink]: http://www.devon-technologies.com/products/devonthink/index.html
110
+ [dublin core]: http://dublincore.org/sm
111
+ [epub]: http://idpf.org/epub
112
+ [git]: http://git-scm.com/
113
+ [github]: http://github.com/
114
+ [git-scribe]: http://github.com/schacon/git-scribe
115
+ [joel on software]: http://www.apress.com/9781590593899
116
+ [joel spolsky]: http://www.joelonsoftware.com/AboutMe.html
117
+ [johnson dt1]: http://www.nytimes.com/2005/01/30/books/review/30JOHNSON.html?_r=1&oref=login
118
+ [johnson dt2]: http://boingboing.net/2009/01/27/diy-how-to-write-a-b.html
119
+ [markdown]: http://daringfireball.net/projects/markdown/
120
+ [microsoft word]: http://office.microsoft.com/word/
121
+ [rake]: http://rake.rubyforge.org/
122
+ [ruby]: http://ruby-lang.org/
123
+ [sproutcore]: http://www.sproutcore.com/
124
+ [sproutcore guides]: http://guides.sproutcore.com/
125
+ [steven johnson]: http://www.stevenberlinjohnson.com/
126
+ [textile]: http://www.textism.com/tools/textile/
@@ -0,0 +1,8 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ desc "Run all examples"
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ - Flesh out validation
2
+ Consider moving details to format modules.
3
+ Investigate Mobi validity constraints to see whether that makes sense.
4
+ - Add more metadata support
5
+ - subtitle
6
+ - different kinds of authors
7
+ - Title page
8
+ - Title image
9
+ - Image support
10
+ - A basic CSS file
11
+ - Support for alternative CSS files
12
+ - Frontmatter
13
+ - Nested chapter structures
14
+ - Generate Mobi files
@@ -0,0 +1,37 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "bindery/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "bindery"
7
+ s.version = Bindery::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Glenn Vanderburg"]
10
+ s.email = ["glv@vanderburg.org"]
11
+ s.homepage = "http://github.com/glv/bindery"
12
+ s.summary = %q{Easy ebook packaging with Ruby}
13
+ s.description = %q{Bindery is a Ruby library for easy packaging of ebooks.
14
+ You supply the chapter content (in HTML format) and explain the book's structure to bindery,
15
+ and bindery generates the various other files required by ebook formats and assembles them
16
+ into a completed book suitable for installation on an ebook reader.}
17
+
18
+ s.rubyforge_project = "bindery"
19
+
20
+ s.files = `git ls-files`.split("\n")
21
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
22
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
23
+ s.require_paths = ["lib"]
24
+
25
+ add_runtime_dependency = if s.respond_to?(:specification_version) && Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
26
+ :add_runtime_dependency
27
+ else
28
+ :add_dependency
29
+ end
30
+ s.send(add_runtime_dependency, 'builder')
31
+ s.send(add_runtime_dependency, 'nokogiri')
32
+ s.send(add_runtime_dependency, 'zip')
33
+
34
+ s.add_development_dependency 'mocha'
35
+ s.add_development_dependency 'rake'
36
+ s.add_development_dependency 'rspec', ['~> 2.0']
37
+ end
@@ -0,0 +1,58 @@
1
+ require 'bindery'
2
+ require 'open-uri'
3
+ require 'nokogiri'
4
+ require 'tidy_ffi'
5
+
6
+ # In March, 2011, Errol Morris published on the New York Times website
7
+ # a five-part reminiscence of a long-ago encounter with famed philosopher
8
+ # Thomas Kuhn. It's a perfect example of the kind of thing Bindery was
9
+ # designed for. You could always use Instapaper to allow you to read it
10
+ # later, but the fact that it's in five pieces means that it wouldn't
11
+ # appear as a single piece of writing in the Instapaper apps; additionally,
12
+ # it's long enough that you might well have to stop reading in the middle
13
+ # of one of the articles, and Instapaper doesn't remember your stopping
14
+ # point or synch it between devices. (Not to mention that it doesn't let
15
+ # you highlight or make notes.) An ebook is simply a better format for some
16
+ # kinds of writing.
17
+ #
18
+ # Note: I occasionally write Bindery scripts like this to bundle writing
19
+ # from the web into a more convenient form for my own personal use. In
20
+ # that respect, I find it no different (as a matter of copyright) than
21
+ # using a service like Instapaper. However, it would definitely be a
22
+ # violation of Morris' and the New York Times' copyrights to distribute
23
+ # the resulting ebook to others. Don't do that!
24
+
25
+ PARTS = [
26
+ 'http://opinionator.blogs.nytimes.com/2011/03/06/the-ashtray-the-ultimatum-part-1/',
27
+ 'http://opinionator.blogs.nytimes.com/2011/03/07/the-ashtray-shifting-paradigms-part-2/',
28
+ 'http://opinionator.blogs.nytimes.com/2011/03/08/the-ashtray-hippasus-of-metapontum-part-3/',
29
+ 'http://opinionator.blogs.nytimes.com/2011/03/09/the-ashtray-the-author-of-the-quixote-part-4/',
30
+ 'http://opinionator.blogs.nytimes.com/2011/03/10/the-ashtray-this-contest-of-interpretation-part-5/'
31
+ ]
32
+
33
+ def process_part(url, i)
34
+ file_name = "chapter_#{i+1}.xhtml_gen"
35
+ doc = Nokogiri::HTML(open(url))
36
+ #puts doc
37
+ title = doc.at_css('h1.entry-title').text.sub(/^.*?: (.*) \(Part \d\)\s*$/, '\\1')
38
+ open(file_name, 'w') do |os|
39
+ content = doc.at_css('div.entry-content')
40
+ content.children[0..1].each{|c| c.remove}
41
+ os.write content.serialize
42
+ end
43
+ { :title => title, :file => file_name }
44
+ end
45
+
46
+ Bindery.book do |b|
47
+ b.output 'the_ashtray'
48
+ b.format :epub
49
+
50
+ b.title "The Ashtray"
51
+ b.author 'Errol Morris'
52
+ b.url 'http://glenn.mp/book/the_ashtray'
53
+ b.language 'en'
54
+
55
+ PARTS.each_with_index do |url, i|
56
+ b.chapter { process_part(url, i) }
57
+ end
58
+ end
@@ -0,0 +1,27 @@
1
+ require 'bindery'
2
+
3
+ Bindery.book do |b|
4
+ b.output 'trivial'
5
+ b.format :epub
6
+
7
+ b.title "A Trivial Bindery Example"
8
+ b.author 'Glenn Vanderburg'
9
+ b.url 'http://glenn.mp/book/trivial_example'
10
+ b.language 'en'
11
+
12
+ b.chapter "Chapter 1", 'chapter_1.xhtml'
13
+ b.chapter "Chapter 2", 'chapter_2.xhtml', :body_only => false
14
+ b.chapter "Chapter 3" do
15
+ # It's good practice to have a way to distinguish source files from
16
+ # those generated as part of the build, so you can have a Rake task
17
+ # or something to clean up the intermediate files.
18
+ "chapter_3.xhtml_gen".tap do |filename|
19
+ File.open(filename, "w") do |os|
20
+ os.write %{
21
+ <p>Let's just write this chapter in line, shall we?</p>
22
+ <p>It seems easier that way, when the chapters are so short.</p>
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,3 @@
1
+ <h2>Chapter 1</h2>
2
+
3
+ <p>This chapter is a bit short. (The same is true for the entire book, for that matter.)</p>
@@ -0,0 +1,14 @@
1
+ <?xml version="1.0" encoding="UTF-8" ?>
2
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
3
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
4
+ <head>
5
+ <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
6
+ <title>Chapter 2</title>
7
+ <link rel="stylesheet" href="css/book.css" type="text/css" />
8
+ </head>
9
+ <body>
10
+ <h2>Chapter 2</h2>
11
+
12
+ <p>Unlike Chapter 1, this chapter's source file is a full, valid XHTML file, not just the body.</p>
13
+ </body>
14
+ </html>
@@ -0,0 +1,10 @@
1
+ require 'bindery/extensions/string'
2
+ require 'bindery/extensions/file'
3
+
4
+ require 'bindery/version'
5
+ require 'bindery/bindery'
6
+ require 'bindery/book'
7
+ require 'bindery/book_builder'
8
+ require 'bindery/chapter'
9
+
10
+ require 'bindery/formats/epub'
@@ -0,0 +1,9 @@
1
+ module Bindery
2
+
3
+ def self.book
4
+ builder = ::Bindery::BookBuilder.new
5
+ yield builder
6
+ builder.book.generate if builder.book.valid?
7
+ end
8
+
9
+ end
@@ -0,0 +1,45 @@
1
+ module Bindery
2
+ class Book
3
+ attr_accessor :output, :url, :isbn, :title, :language, :author, :subtitle
4
+
5
+ def formats
6
+ @formats ||= []
7
+ end
8
+
9
+ def chapters
10
+ @chapters ||= []
11
+ end
12
+
13
+ def full_title
14
+ title + (subtitle ? ": #{subtitle}" : '')
15
+ end
16
+
17
+ def valid?
18
+ configuration_valid? && metadata_valid? && chapters_valid?
19
+ end
20
+
21
+ def configuration_valid?
22
+ true
23
+ # formats specified or correctly defaulted
24
+ # ouput specified
25
+ # at least one chapter
26
+ end
27
+
28
+ def metadata_valid?
29
+ true
30
+ # everything required has been specified (must find out what that is)
31
+ # what is there is correct
32
+ end
33
+
34
+ def chapters_valid?
35
+ chapters.all?{|chapter| chapter.valid?} # && chapter file names are unique
36
+ end
37
+
38
+ def generate
39
+ formats.each do |format|
40
+ require "bindery/formats/#{format}"
41
+ ::Bindery::Formats.const_get(format.to_s.capitalize).new(self).generate
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,101 @@
1
+ module Bindery
2
+
3
+ class BookBuilder
4
+ attr_accessor :book
5
+
6
+ def initialize
7
+ self.book = ::Bindery::Book.new
8
+ end
9
+
10
+ def format(fmt)
11
+ raise "unsupported format :#{fmt}" unless [:epub].include?(fmt)
12
+ book.formats << fmt
13
+ end
14
+
15
+ def output(basename)
16
+ raise "output already set to #{book.output}" unless book.output.nil?
17
+ book.output = basename.to_s
18
+ end
19
+
20
+ def url(url)
21
+ book.url = url
22
+ end
23
+
24
+ def isbn(isbn)
25
+ book.isbn = isbn
26
+ end
27
+
28
+ def title(title)
29
+ book.title = title
30
+ end
31
+
32
+ def subtitle(subtitle)
33
+ book.subtitle = subtitle
34
+ end
35
+
36
+ def language(language)
37
+ book.language = language
38
+ end
39
+
40
+ def author(author)
41
+ book.author = author
42
+ end
43
+
44
+ # :call-seq:
45
+ # chapter(title, filename, options={})
46
+ # chapter(title, options={}) { ... }
47
+ # chapter(options={}) { ... }
48
+ #
49
+ # Add a chapter to the book.
50
+ #
51
+ # If called with a title and a filename, the chapter's content should
52
+ # be found in the named file.
53
+ #
54
+ # If called with a title and a block, the block should generate or
55
+ # retrieve the chapter's content, write it to a file, and return the
56
+ # file name.
57
+ #
58
+ # If called with no parameters and a block, the block should generate
59
+ # or retrieve the chapter's content, write it to a file, and return a
60
+ # hash with the following keys:
61
+ # [:title] the chapter title (required)
62
+ # [:file] the name of the file containing the chapter content (required)
63
+ #
64
+ # An options hash parameter is always allowed, and the following
65
+ # options are supported:
66
+ # [:body_only] the file contains only the body of the XHTML document,
67
+ # and Bindery should wrap it to create a valid document.
68
+ # Defaults to true.
69
+ def chapter(*args)
70
+ default_options = {:body_only => true}
71
+ options = default_options.merge(args.last.kind_of?(Hash) ? args.pop : {})
72
+ if block_given?
73
+ chapter_dynamic(options, *args){yield}
74
+ else
75
+ chapter_static(options, *args)
76
+ end
77
+ end
78
+
79
+ protected
80
+
81
+ def chapter_static(options, *args)
82
+ title, file = args
83
+ raise ArgumentError, "title not specified" if title.nil?
84
+ raise ArgumentError, "file not specified" if file.nil?
85
+ book.chapters << Chapter.new(title, file, options)
86
+ end
87
+
88
+ def chapter_dynamic(options, title=nil)
89
+ if title
90
+ file = yield
91
+ raise "expected the block to return a filename string" unless file.kind_of?(String)
92
+ chapter_static(options, title, yield)
93
+ else
94
+ info = yield
95
+ raise "expected the block to return a hash containing :title, :file, etc." unless info.kind_of?(Hash)
96
+ chapter_static(options, info[:title], info[:file])
97
+ end
98
+ end
99
+ end
100
+
101
+ end
@@ -0,0 +1,28 @@
1
+ module Bindery
2
+ class Chapter
3
+ attr_accessor :file
4
+ attr_accessor :title
5
+ attr_accessor :options
6
+
7
+ def initialize(title, file, options)
8
+ self.title = title
9
+ self.file = file
10
+ self.options = options
11
+ end
12
+
13
+ def valid?
14
+ true
15
+ # title specified
16
+ # file exists, readable
17
+ # file content properly formed? Does that matter? Can we verify it?
18
+ end
19
+
20
+ def body_only?
21
+ options.fetch(:body_only, true)
22
+ end
23
+
24
+ def include_images?
25
+ options.fetch(:include_images, true)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ module Bindery
2
+ module Extensions
3
+ module FileClassMethods
4
+ def base_parts(fn)
5
+ ext = File.extname(fn)
6
+ [File.basename(fn, ext), ext]
7
+ end
8
+
9
+ def stemname(fn)
10
+ base_parts(fn)[0]
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ File.extend(Bindery::Extensions::FileClassMethods)
@@ -0,0 +1,12 @@
1
+ module Bindery
2
+ module Extensions
3
+ module StringExtensions
4
+ # stolen from Scala
5
+ def strip_margin
6
+ gsub(/^\s*\|/m, '')
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ String.send(:include, Bindery::Extensions::StringExtensions)
@@ -0,0 +1,28 @@
1
+ require 'zip'
2
+
3
+ module Bindery
4
+ module Extensions
5
+ module ZipFileExtensions
6
+ def write_file(entry, contents)
7
+ get_output_stream(entry){|os| os.write(contents) }
8
+ end
9
+
10
+ def write_uncompressed_file(entry_name, contents)
11
+ entry = Zip::ZipEntry.new(@name, entry_name.to_s)
12
+ entry.compression_method = Zip::ZipEntry::STORED
13
+ write_file(entry, contents)
14
+ end
15
+ end
16
+
17
+ module ZipStreamableStreamExtensions
18
+ def kind_of?(thing)
19
+ # ZipStreamableStream is a ZipEntry, but through delegation, not inheritance.
20
+ return true if thing == ::Zip::ZipEntry
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ Zip::ZipFile.send(:include, Bindery::Extensions::ZipFileExtensions)
28
+ Zip::ZipStreamableStream.send(:include, Bindery::Extensions::ZipStreamableStreamExtensions)
@@ -0,0 +1,317 @@
1
+ require 'builder'
2
+ require 'zip'
3
+ require 'bindery/extensions/zip_file'
4
+ require 'nokogiri'
5
+
6
+ module Bindery
7
+ module Formats
8
+
9
+ # Builds an EPUB book file from the book description.
10
+ #
11
+ # The {EPUB Wikipedia entry}[http://en.wikipedia.org/wiki/EPUB] provides a nice, concise overview of the EPUB format.
12
+ #
13
+ # For more precise details:
14
+ # * The overall structure of an EPUB file is documented in
15
+ # {Open Container Format (OCF) 2.0.1 - Recommended Specification}[http://idpf.org/epub/20/spec/OCF_2.0.1_draft.doc].
16
+ # * The format of the OPF file is documented in
17
+ # {Open Packaging Format (OPF) 2.0.1 - Recommended Specification}[http://idpf.org/epub/20/spec/OPF_2.0.1_draft.htm].
18
+ # * The format of the NCX file is documented in
19
+ # {Section 8 of "Specifications for the Digital Talking Book"}[http://www.niso.org/workrooms/daisy/Z39-86-2005.html#NCX].
20
+ # * Details of the format of other files allowed in EPUB documents are found in
21
+ # {Open Publication Structure (OPS) 2.0.1 - Recommended Specification}[http://idpf.org/epub/20/spec/OPS_2.0.1_draft.htm].
22
+ class Epub
23
+
24
+ MimeTypes = {
25
+ '.jpg' => 'image/jpeg',
26
+ '.png' => 'image/png',
27
+ '.gif' => 'image/gif',
28
+ }
29
+
30
+ class ManifestEntry < Struct.new(:file_name, :xml_id, :mime_type)
31
+ end
32
+
33
+ attr_accessor :book, :manifest_entries
34
+
35
+ def initialize(book)
36
+ self.book = book
37
+ book.extend BookMethods
38
+ book.chapters.each{|chapter| chapter.extend ChapterMethods}
39
+ self.manifest_entries = []
40
+ end
41
+
42
+ def generate
43
+ File.delete(book.epub_output_file) if File.exist?(book.epub_output_file)
44
+ Zip::ZipFile.open(book.epub_output_file, Zip::ZipFile::CREATE) do |zipfile|
45
+ # FIXME: The mimetype file is supposed to be the first one in the Zip directory, but that doesn't seem to be happening.
46
+ zipfile.write_uncompressed_file 'mimetype', mimetype
47
+ zipfile.mkdir 'META-INF'
48
+ zipfile.write_file 'META-INF/container.xml', container
49
+
50
+ # also frontmatter, backmatter
51
+ book.chapters.each do |chapter|
52
+ write_chapter(chapter, zipfile)
53
+ end
54
+
55
+ zipfile.mkdir 'css'
56
+ zipfile.write_file 'css/book.css', stylesheet
57
+
58
+ zipfile.write_file 'book.opf', opf
59
+ zipfile.write_file 'book.ncx', ncx
60
+ end
61
+ end
62
+
63
+ def mimetype
64
+ # the mimetype file must be the first file in the archive
65
+ # it must be ASCII, uncompressed, and unencrypted
66
+ 'application/epub+zip'
67
+ end
68
+
69
+ def container
70
+ %q{|<?xml version="1.0" encoding="UTF-8" ?>
71
+ |<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
72
+ | <rootfiles>
73
+ | <rootfile full-path="book.opf" media-type="application/oebps-package+xml"/>
74
+ | </rootfiles>
75
+ |</container>
76
+ |}.strip_margin
77
+ end
78
+
79
+ def opf
80
+ xm = Builder::XmlMarkup.new(:indent => 2)
81
+ xm.instruct!
82
+ xm.package('version'=>'2.0', 'xmlns'=>'http://www.idpf.org/2007/opf', 'unique-identifier'=>'BookId') {
83
+
84
+ xm.metadata('xmlns:dc'=>'http://purl.org/dc/elements/1.1/', 'xmlns:opf'=>'http://www.idpf.org/2007/opf') {
85
+ # required elements
86
+ xm.dc :title, book.full_title
87
+ xm.dc :language, book.language
88
+ xm.dc :identifier, book.url, ident_options('opf:scheme'=>'URL') if book.url
89
+ xm.dc :identifier, book.isbn, ident_options('opf:scheme'=>'ISBN') if book.isbn
90
+
91
+ # optional elements
92
+ xm.dc :creator, book.author, 'opf:role'=>'aut' if book.author
93
+ }
94
+
95
+ xm.manifest {
96
+ book.chapters.each{|chapter| xm.item 'id'=>chapter.epub_id, 'href'=>chapter.epub_output_file, 'media-type'=>'application/xhtml+xml'}
97
+ # also frontmatter, backmatter
98
+ xm.item 'id'=>'stylesheet', 'href'=>'css/book.css', 'media-type'=>'text/css'
99
+ manifest_entries.each do |entry|
100
+ xm.item 'id'=>entry.xml_id, 'href'=>entry.file_name, 'media-type'=>entry.mime_type
101
+ end
102
+ # xm.item 'id'=>'myfont', 'href'=>'css/myfont.otf', 'media-type'=>'application/x-font-opentype'
103
+ xm.item 'id'=>'ncx', 'href'=>'book.ncx', 'media-type'=>'application/x-dtbncx+xml'
104
+ }
105
+
106
+ xm.spine('toc'=>'ncx') {
107
+ book.chapters.each{|chapter| xm.itemref 'idref'=>chapter.epub_id}
108
+ }
109
+
110
+ # xm.guide {
111
+ # xm.reference 'type'='loi', 'title'=>'List of Illustrations', 'href'=>'appendix.html#figures'
112
+ # }
113
+ }
114
+ end
115
+
116
+ def ncx
117
+ xm = Builder::XmlMarkup.new(:indent => 2)
118
+ xm.instruct!
119
+ xm.declare!(:DOCTYPE, :ncx, :PUBLIC, '-//NISO//DTD ncx 2005-1//EN', 'http://www.daisy.org/z3986/2005/ncx-2005-1.dtd')
120
+ xm.ncx('version'=>'2005-1', 'xml:lang'=>'en', 'xmlns'=>'http://www.daisy.org/z3986/2005/ncx/') {
121
+ xm.head {
122
+ xm.meta 'name'=>'dtb:uid', 'content'=>book.ident
123
+ xm.meta 'name'=>'dtb:depth', 'content'=>book.depth
124
+ xm.meta 'name'=>'dtb:totalPageCount', 'content'=>0
125
+ xm.meta 'name'=>'dtb:maxPageNumber', 'content'=>0
126
+ }
127
+
128
+ xm.docTitle {
129
+ xm.text book.full_title
130
+ }
131
+
132
+ xm.docAuthor {
133
+ xm.text book.author
134
+ }
135
+
136
+ xm.navMap {
137
+ play_order = 1
138
+
139
+ # also frontmatter, backmatter
140
+ book.chapters.each do |chapter|
141
+ xm.navPoint('class'=>'chapter', 'id'=>chapter.epub_id, 'playOrder'=>play_order) {
142
+ xm.navLabel {
143
+ xm.text chapter.title
144
+ }
145
+ xm.content 'src'=>chapter.epub_output_file
146
+ }
147
+ play_order += 1
148
+ end
149
+ }
150
+ }
151
+ end
152
+
153
+ def write_chapter(chapter, zipfile)
154
+ save_options = Nokogiri::XML::Node::SaveOptions
155
+ File.open(chapter.file, 'r:UTF-8') do |ch_in|
156
+ doc = Nokogiri.HTML(ch_in.read)
157
+ include_images(doc, zipfile) if chapter.include_images?
158
+ zipfile.get_output_stream(chapter.epub_output_file) do |ch_out|
159
+ if chapter.body_only?
160
+ # FIXME: must HTML-escape the chapter title
161
+ ch_out.write %{|<?xml version="1.0" encoding="UTF-8" ?>
162
+ |<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
163
+ |<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
164
+ |<head>
165
+ | <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
166
+ | <title>#{chapter.title}</title>
167
+ | <link rel="stylesheet" href="css/book.css" type="text/css" />
168
+ |</head>
169
+ |}.strip_margin
170
+ ch_out.write doc.at('body').serialize(:save_with => (save_options::AS_XHTML | save_options::NO_DECLARATION))
171
+ ch_out.write %{|</html>
172
+ |}.strip_margin
173
+ else
174
+ ch_out.write doc.serialize(:save_with => save_options::AS_XHTML)
175
+ end
176
+ end
177
+ end
178
+ end
179
+
180
+ def include_images(doc, zipfile)
181
+ # TODO: where else can images appear? Style sheets?
182
+ zipfile.mkdir('images') unless zip_dir_exists?(zipfile, 'images')
183
+ doc.css('img').each do |img|
184
+ url = img['src']
185
+ img_fn = make_image_file_name(zipfile, url)
186
+ # TODO: These images should be cached somewhere for multi-format runs
187
+ open(url, 'r') do |is|
188
+ zipfile.get_output_stream(img_fn) do |os|
189
+ os.write is.read
190
+ end
191
+ end
192
+ add_manifest_entry(img_fn)
193
+ img['src'] = img_fn
194
+ end
195
+ end
196
+
197
+ def add_manifest_entry(file_name)
198
+ xml_id, ext = File.base_parts(file_name.gsub('/', '-'))
199
+ manifest_entries << ManifestEntry.new(file_name, xml_id, MimeTypes[ext])
200
+ end
201
+
202
+ def cover
203
+ xm = Builder::XmlMarkup.new(:indent => 2)
204
+ xm.instruct!
205
+ xm.declare!(:DOCTYPE, :html, :PUBLIC, '-//W3C//DTD XHTML 1.1//END', 'http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd')
206
+ xm.html('xmlns'=>'http://www.w3.org/1999/xhtml') { # ??? xml:lang attribute?
207
+ xm.head {
208
+ xm.title "#{book.title}: Cover"
209
+ xm.meta('http-equiv'=>'Content-Type', 'content'=>'application/xhtml+xml; charset=utf-8')
210
+ }
211
+ xm.body {
212
+ xm.div('style'=>'text-align: center; page-break-after: always;') {
213
+ if book.cover
214
+ xm.img('src'=>"images/#{book.cover}", 'alt'=>book.title, 'style'=>'height: 100%; max-width: 100%;')
215
+ else
216
+ xm.h1 book.title
217
+ xm.h2 book.subtitle if book.subtitle
218
+ xm.h3 "by #{book.author}" if book.author
219
+ end
220
+ }
221
+ }
222
+ }
223
+ end
224
+
225
+ def stylesheet
226
+ # This is a start, but needs work.
227
+ %q{|@page {
228
+ | margin-top: 0.8em;
229
+ | margin-bottom: 0.8em;}
230
+ |
231
+ |body {
232
+ | margin-left: 1em;
233
+ | margin-right: 1em;
234
+ | padding: 0;}
235
+ |
236
+ |h2 {
237
+ | padding-top:0;
238
+ | display:block;}
239
+ |
240
+ |p {
241
+ | margin-top: 0.3em;
242
+ | margin-bottom: 0.3em;
243
+ | text-indent: 1.0em;
244
+ | text-align: justify;}
245
+ |
246
+ |body > p:first-child {text-indent: 0}
247
+ |div.text p:first-child {text-indent: 0}
248
+ |
249
+ |blockquote p, li p {
250
+ | text-indent: 0.0em;
251
+ | text-align: left;}
252
+ |
253
+ |div.chapter {padding-top: 3.0em;}
254
+ |div.part {padding-top: 3.0em;}
255
+ |h3.section_title {text-align: center;}
256
+ |}.strip_margin
257
+ end
258
+
259
+ def ident_options(opts)
260
+ if book.isbn
261
+ return opts.merge('id'=>'BookId') if opts['opf:scheme'] == 'ISBN'
262
+ else
263
+ return opts.merge('id'=>'BookId') if opts['opf:scheme'] == 'URL'
264
+ end
265
+ opts
266
+ end
267
+
268
+ def zip_dir_exists?(zipfile, dirname)
269
+ dirname = "#{dirname}/" unless dirname =~ %r{/$}
270
+ zipfile.entries.any?{|e| e.directory? && e.name == dirname}
271
+ end
272
+
273
+ def zip_file_exists?(zipfile, filename)
274
+ zipfile.entries.any?{|e| e.name == filename}
275
+ end
276
+
277
+ def make_image_file_name(zipfile, url)
278
+ stem, ext = File.base_parts(url)
279
+ filename = "images/#{stem}#{ext}"
280
+ n = 0
281
+ while zip_file_exists?(zipfile, filename)
282
+ n += 1
283
+ filename = "#{stem}_#{n}#{ext}"
284
+ end
285
+ filename
286
+ end
287
+
288
+ module BookMethods
289
+ def epub_output_file
290
+ @epub_output_file ||= "#{output}.epub"
291
+ end
292
+
293
+ def depth
294
+ 1
295
+ end
296
+
297
+ def ident
298
+ isbn || url
299
+ end
300
+ end
301
+
302
+ module ChapterMethods
303
+ def epub_id
304
+ @epub_id ||= File.stemname(file)
305
+ end
306
+
307
+ def epub_output_file
308
+ @epub_output_file ||= "#{epub_id}.xhtml"
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
314
+
315
+ # Notes:
316
+ # cover image file: in manifest, then in metadata as <meta name="cover" content="manifest-entry-id"/>
317
+ # cover: xhtml file, in manifest, also in spine as <itemref idref="manifest-entry-id" linear="no"/>, also in guide as <reference type="cover" title="Cover" href="cover.xhtml"/> (why does that use a direct href instead of an id ref?)
@@ -0,0 +1,3 @@
1
+ module Bindery
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,25 @@
1
+ require 'spec_helper'
2
+
3
+ describe Bindery do
4
+ describe ".book" do
5
+ it "yields a new BookBuilder object to the block" do
6
+ Bindery.book do |b|
7
+ b.should be_kind_of Bindery::BookBuilder
8
+ end
9
+ end
10
+
11
+ it "generates the book after the block returns if the book is valid" do
12
+ Bindery.book do |b|
13
+ b.book.expects(:valid?).once.returns(true)
14
+ b.book.expects(:generate).once
15
+ end
16
+ end
17
+
18
+ it "does not generate the book if the book is not valid" do
19
+ Bindery.book do |b|
20
+ b.book.expects(:valid?).once.returns(false)
21
+ b.book.expects(:generate).never
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,90 @@
1
+ require 'spec_helper'
2
+
3
+ describe Bindery::BookBuilder do
4
+ let(:book) { subject.book }
5
+
6
+ describe "#book" do
7
+ it "returns the configured Book instance" do
8
+ book.should be_kind_of(Bindery::Book)
9
+ end
10
+ end
11
+
12
+ describe "#output" do
13
+ it "stores the filename to use (minus suffix) for output book files" do
14
+ subject.output 'foo'
15
+ book.output.should == 'foo'
16
+ end
17
+
18
+ it "converts its argument to a string before storing it" do
19
+ subject.output :foo
20
+ book.output.should == 'foo'
21
+ end
22
+
23
+ it "raises an error if the output has already been set" do
24
+ subject.output 'bar'
25
+ expect { subject.output 'foo' }.to raise_error
26
+ end
27
+ end
28
+
29
+ describe "#format" do
30
+ it "raises an exception if the supplied format is unsupported" do
31
+ expect { subject.format :interpress }.to raise_error
32
+ expect { subject.format :mobi }.to raise_error
33
+ end
34
+
35
+ it "adds the supplied format to the list of formats to generate for this book" do
36
+ subject.format :epub
37
+ book.formats.should =~ [:epub]
38
+ end
39
+ end
40
+
41
+ describe "#chapter" do
42
+
43
+ context "when called without a block" do
44
+ it "raises an exception if title or filename are not supplied" do
45
+ expect { subject.chapter }.to raise_error(ArgumentError, "title not specified")
46
+ expect { subject.chapter "A" }.to raise_error(ArgumentError, "file not specified")
47
+ end
48
+
49
+ it "stores a new chapter with the supplied options" do
50
+ subject.chapter "Foo", "bar.xhtml"
51
+ book.chapters.should have(1).elements
52
+ book.chapters.first.title.should == "Foo"
53
+ book.chapters.first.file.should == "bar.xhtml"
54
+ end
55
+ end
56
+
57
+ context "when called with a block" do
58
+
59
+ context "and title is supplied as an argument" do
60
+ it "expects the block to result in a filename string" do
61
+ expect { subject.chapter("Foo"){ 3 } }.to raise_error
62
+ end
63
+
64
+ it "stores the title and the filename as a new chapter" do
65
+ subject.chapter("Foo"){ 'bar.xhtml' }
66
+ book.chapters.should have(1).elements
67
+ book.chapters.first.title.should == "Foo"
68
+ book.chapters.first.file.should == "bar.xhtml"
69
+ end
70
+ end
71
+
72
+ context "and title is not supplied as an argument" do
73
+ it "expects the block to result in a hash with :title and :file options" do
74
+ expect { subject.chapter{ "Invalid" } }.to raise_error
75
+ expect { subject.chapter{ {:title => 'Foo'} } }.to raise_error
76
+ expect { subject.chapter{ {:file => 'bar.xhtml'} } }.to raise_error
77
+ end
78
+
79
+ it "stores the title and the filename as a new chapter" do
80
+ subject.chapter { {:title => 'Foo', :file => 'bar.xhtml'} }
81
+ book.chapters.should have(1).elements
82
+ book.chapters.first.title.should == "Foo"
83
+ book.chapters.first.file.should == "bar.xhtml"
84
+ end
85
+ end
86
+
87
+ end
88
+
89
+ end
90
+ end
@@ -0,0 +1,18 @@
1
+ require 'rspec/core'
2
+
3
+ require 'bindery'
4
+
5
+ Dir['./spec/support/**/*.rb'].map {|f| require f}
6
+
7
+ def in_editor?
8
+ ENV.has_key?('TM_MODE') || ENV.has_key?('EMACS') || ENV.has_key?('VIM')
9
+ end
10
+
11
+ RSpec.configure do |c|
12
+ c.color_enabled = !in_editor?
13
+ c.filter_run :focus => true
14
+ c.mock_with :mocha
15
+ c.run_all_when_everything_filtered = true
16
+ c.add_formatter :progress
17
+ c.add_formatter :documentation, 'rspec.txt'
18
+ end
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bindery
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.0.2
6
+ platform: ruby
7
+ authors:
8
+ - Glenn Vanderburg
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2011-06-18 00:00:00 -05:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: builder
18
+ requirement: &id001 !ruby/object:Gem::Requirement
19
+ none: false
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: *id001
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: &id002 !ruby/object:Gem::Requirement
30
+ none: false
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: "0"
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: *id002
38
+ - !ruby/object:Gem::Dependency
39
+ name: zip
40
+ requirement: &id003 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: "0"
46
+ type: :runtime
47
+ prerelease: false
48
+ version_requirements: *id003
49
+ - !ruby/object:Gem::Dependency
50
+ name: mocha
51
+ requirement: &id004 !ruby/object:Gem::Requirement
52
+ none: false
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: "0"
57
+ type: :development
58
+ prerelease: false
59
+ version_requirements: *id004
60
+ - !ruby/object:Gem::Dependency
61
+ name: rake
62
+ requirement: &id005 !ruby/object:Gem::Requirement
63
+ none: false
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: "0"
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: *id005
71
+ - !ruby/object:Gem::Dependency
72
+ name: rspec
73
+ requirement: &id006 !ruby/object:Gem::Requirement
74
+ none: false
75
+ requirements:
76
+ - - ~>
77
+ - !ruby/object:Gem::Version
78
+ version: "2.0"
79
+ type: :development
80
+ prerelease: false
81
+ version_requirements: *id006
82
+ description: |-
83
+ Bindery is a Ruby library for easy packaging of ebooks.
84
+ You supply the chapter content (in HTML format) and explain the book's structure to bindery,
85
+ and bindery generates the various other files required by ebook formats and assembles them
86
+ into a completed book suitable for installation on an ebook reader.
87
+ email:
88
+ - glv@vanderburg.org
89
+ executables: []
90
+
91
+ extensions: []
92
+
93
+ extra_rdoc_files: []
94
+
95
+ files:
96
+ - .gitignore
97
+ - Gemfile
98
+ - README.md
99
+ - Rakefile
100
+ - TODO.taskpaper
101
+ - bindery.gemspec
102
+ - examples/ashtray/book.rb
103
+ - examples/trivial/book.rb
104
+ - examples/trivial/chapter_1.xhtml
105
+ - examples/trivial/chapter_2.xhtml
106
+ - lib/bindery.rb
107
+ - lib/bindery/bindery.rb
108
+ - lib/bindery/book.rb
109
+ - lib/bindery/book_builder.rb
110
+ - lib/bindery/chapter.rb
111
+ - lib/bindery/extensions/file.rb
112
+ - lib/bindery/extensions/string.rb
113
+ - lib/bindery/extensions/zip_file.rb
114
+ - lib/bindery/formats/epub.rb
115
+ - lib/bindery/version.rb
116
+ - spec/bindery/bindery_spec.rb
117
+ - spec/bindery/book_builder_spec.rb
118
+ - spec/spec_helper.rb
119
+ has_rdoc: true
120
+ homepage: http://github.com/glv/bindery
121
+ licenses: []
122
+
123
+ post_install_message:
124
+ rdoc_options: []
125
+
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ hash: -3490394669822348887
134
+ segments:
135
+ - 0
136
+ version: "0"
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ none: false
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ hash: -3490394669822348887
143
+ segments:
144
+ - 0
145
+ version: "0"
146
+ requirements: []
147
+
148
+ rubyforge_project: bindery
149
+ rubygems_version: 1.5.2
150
+ signing_key:
151
+ specification_version: 3
152
+ summary: Easy ebook packaging with Ruby
153
+ test_files:
154
+ - spec/bindery/bindery_spec.rb
155
+ - spec/bindery/book_builder_spec.rb
156
+ - spec/spec_helper.rb