asciidoctor-epub3 1.5.0.alpha.6 → 1.5.0.alpha.7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +134 -0
  3. data/Gemfile +10 -0
  4. data/README.adoc +248 -172
  5. data/asciidoctor-epub3.gemspec +42 -0
  6. data/bin/adb-push-ebook +19 -10
  7. data/data/styles/epub3-css3-only.css +28 -11
  8. data/data/styles/epub3.css +40 -39
  9. data/lib/asciidoctor-epub3/converter.rb +188 -121
  10. data/lib/asciidoctor-epub3/core_ext/string.rb +1 -1
  11. data/lib/asciidoctor-epub3/font_icon_map.rb +1 -1
  12. data/lib/asciidoctor-epub3/packager.rb +240 -104
  13. data/lib/asciidoctor-epub3/spine_item_processor.rb +22 -11
  14. data/lib/asciidoctor-epub3/version.rb +1 -1
  15. metadata +24 -35
  16. data/data/samples/asciidoctor-epub3-readme.adoc +0 -849
  17. data/data/samples/asciidoctor-js-browser-extension.adoc +0 -46
  18. data/data/samples/asciidoctor-js-introduction.adoc +0 -91
  19. data/data/samples/i18n.adoc +0 -161
  20. data/data/samples/images/asciidoctor-js-chrome-extension.png +0 -0
  21. data/data/samples/images/avatars/graphitefriction.jpg +0 -0
  22. data/data/samples/images/avatars/mogztter.jpg +0 -0
  23. data/data/samples/images/avatars/mojavelinux.jpg +0 -0
  24. data/data/samples/images/correct-text-justification.png +0 -0
  25. data/data/samples/images/incorrect-text-justification.png +0 -0
  26. data/data/samples/images/screenshots/chapter-title-day.png +0 -0
  27. data/data/samples/images/screenshots/chapter-title.png +0 -0
  28. data/data/samples/images/screenshots/figure-admonition.png +0 -0
  29. data/data/samples/images/screenshots/section-title-paragraph.png +0 -0
  30. data/data/samples/images/screenshots/sidebar.png +0 -0
  31. data/data/samples/images/screenshots/table.png +0 -0
  32. data/data/samples/images/screenshots/text.png +0 -0
  33. data/data/samples/sample-book.adoc +0 -21
  34. data/data/samples/sample-content.adoc +0 -168
  35. data/scripts/generate-font-subsets.pe +0 -235
@@ -3,5 +3,5 @@ require 'stringio' unless defined? StringIO
3
3
  class String
4
4
  def to_ios
5
5
  StringIO.new self
6
- end unless String.respond_to? :to_ios
6
+ end unless method_defined? :to_ios
7
7
  end
@@ -1,6 +1,6 @@
1
1
  module Asciidoctor
2
2
  module Epub3
3
- # Map of Font Awesome icon names to unicode characters
3
+ # Map of Font Awesome icon names to unicode characters
4
4
  FontIconMap = {
5
5
  glass: '\f000',
6
6
  music: '\f001',
@@ -8,26 +8,46 @@ module Epub3
8
8
  module GepubBuilderMixin
9
9
  DATA_DIR = ::File.expand_path(::File.join ::File.dirname(__FILE__), '..', '..', 'data')
10
10
  SAMPLES_DIR = ::File.join DATA_DIR, 'samples'
11
- WordJoinerRx = Epub3::WordJoinerRx
11
+ CharEntityRx = ContentConverter::CharEntityRx
12
+ XmlElementRx = ContentConverter::XmlElementRx
12
13
  FromHtmlSpecialCharsMap = ContentConverter::FromHtmlSpecialCharsMap
13
14
  FromHtmlSpecialCharsRx = ContentConverter::FromHtmlSpecialCharsRx
14
15
  CsvDelimiterRx = /\s*,\s*/
15
16
  DefaultCoverImage = 'images/default-cover.png'
16
- InlineImageMacroRx = /^image:(.*?)\[(.*?)\]$/
17
+ ImageMacroRx = /^image::?(.*?)\[(.*?)\]$/
18
+ ImgSrcScanRx = /<img src="(.+?)"/
19
+ SvgImgSniffRx = /<img src=".+?\.svg"/
17
20
 
18
- def sanitized_doctitle doc, target = :plain
19
- return (doc.attr 'untitled-label') unless doc.header?
20
- title = case target
21
+ attr_reader :book, :format, :spine
22
+
23
+ # FIXME move to Asciidoctor::Helpers
24
+ def sanitize_doctitle_xml doc, content_spec
25
+ doctitle = doc.header? ? doc.doctitle : (doc.attr 'untitled-label')
26
+ sanitize_xml doctitle, content_spec
27
+ end
28
+
29
+ # FIXME move to Asciidoctor::Helpers
30
+ def sanitize_xml content, content_spec
31
+ if content_spec != :pcdata && (content.include? '<')
32
+ if (content = (content.gsub XmlElementRx, '').strip).include? ' '
33
+ content = content.tr_s ' ', ' '
34
+ end
35
+ end
36
+
37
+ case content_spec
21
38
  when :attribute_cdata
22
- doc.doctitle(sanitize: true).gsub('"', '&quot;')
23
- when :element_cdata
24
- doc.doctitle sanitize: true
25
- when :pcdata
26
- doc.doctitle
27
- when :plain
28
- doc.doctitle(sanitize: true).gsub(FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap)
39
+ content = content.gsub '"', '&quot;' if content.include? '"'
40
+ when :cdata, :pcdata
41
+ # noop
42
+ when :plain_text
43
+ if content.include? ';'
44
+ content = content.gsub(CharEntityRx) { [$1.to_i].pack 'U*' } if content.include? '&#'
45
+ content = content.gsub FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap
46
+ end
47
+ else
48
+ raise ::ArgumentError, %(Unknown content spec: #{content_spec})
29
49
  end
30
- title.gsub WordJoinerRx, ''
50
+ content
31
51
  end
32
52
 
33
53
  def add_theme_assets doc
@@ -81,50 +101,61 @@ module GepubBuilderMixin
81
101
  end
82
102
  end
83
103
  end
104
+ nil
84
105
  end
85
106
 
86
107
  def add_cover_image doc
87
108
  imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
88
109
  imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
89
110
 
90
- if (front_cover_image = doc.attr 'front-cover-image')
91
- if front_cover_image =~ InlineImageMacroRx
92
- front_cover_image = %(#{imagesdir}#{$1})
111
+ if (image_path = doc.attr 'front-cover-image')
112
+ image_attrs = {}
113
+ if (image_path.include? ':') && image_path =~ ImageMacroRx
114
+ if image_path.start_with? 'image::'
115
+ warn %(asciidoctor: WARNING: deprecated block macro syntax detected in front-cover-image attribute)
116
+ end
117
+ image_path = %(#{imagesdir}#{$1})
118
+ (::Asciidoctor::AttributeList.new $2).parse_into image_attrs, %w(alt width height) unless $2.empty?
93
119
  end
94
- workdir = doc.attr 'docdir', '.'
95
- workdir = '.' if workdir.empty?
96
- else
97
- front_cover_image = DefaultCoverImage
98
- workdir = DATA_DIR
120
+ workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
121
+ if ::File.readable?(::File.join workdir, image_path)
122
+ unless !image_attrs.empty? && (width = image_attrs['width']) && (height = image_attrs['height'])
123
+ width, height = 1050, 1600
124
+ end
125
+ else
126
+ warn %(asciidoctor: ERROR: front cover image not found or readable: #{image_path})
127
+ image_path = nil
128
+ end
129
+ end
130
+
131
+ unless image_path
132
+ image_path, workdir, width, height = DefaultCoverImage, DATA_DIR, 1050, 1600
99
133
  end
100
134
 
101
135
  resources do
102
- cover_image %(#{imagesdir}jacket/cover#{::File.extname front_cover_image}) => ::File.join(workdir, front_cover_image)
136
+ cover_image %(#{imagesdir}jacket/cover#{::File.extname image_path}) => (::File.join workdir, image_path)
137
+ @last_defined_item.tap do |last_item|
138
+ last_item['width'] = width
139
+ last_item['height'] = height
140
+ end
103
141
  end
142
+ nil
104
143
  end
105
144
 
106
145
  # NOTE must be called within the ordered block
107
- def add_cover_page doc, spine_builder, book
108
- imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
109
- imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
110
-
111
- img = (doc.attr 'front-cover-image') || DefaultCoverImage
112
-
113
- if img =~ InlineImageMacroRx
114
- img = %(#{imagesdir}#{$1})
115
- # TODO use proper attribute parser
116
- _, w, h = $2.split ',', 3
117
- end
146
+ def add_cover_page doc, spine_builder, manifest
147
+ cover_item_attrs = manifest.items['item_cover'].instance_variable_get :@attributes
148
+ href = cover_item_attrs['href']
149
+ # NOTE we only store width and height temporarily to pass through the values
150
+ width = cover_item_attrs.delete 'width'
151
+ height = cover_item_attrs.delete 'height'
118
152
 
119
- w ||= 1050
120
- h ||= 1600
121
- img_path = %(#{imagesdir}jacket/cover#{::File.extname img})
122
153
  # NOTE SVG wrapper maintains aspect ratio and confines image to view box
123
154
  content = %(<!DOCTYPE html>
124
155
  <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
125
156
  <head>
126
157
  <meta charset="UTF-8"/>
127
- <title>#{sanitized_doctitle doc, :element_cdata}</title>
158
+ <title>#{sanitize_doctitle_xml doc, :cdata}</title>
128
159
  <style type="text/css">
129
160
  @page {
130
161
  margin: 0;
@@ -145,60 +176,64 @@ body > svg {
145
176
  </style>
146
177
  </head>
147
178
  <body epub:type="cover"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
148
- width="100%" height="100%" viewBox="0 0 #{w} #{h}" preserveAspectRatio="xMidYMid meet">
149
- <image width="#{w}" height="#{h}" xlink:href="#{img_path}"/>
179
+ width="100%" height="100%" viewBox="0 0 #{width} #{height}" preserveAspectRatio="xMidYMid meet">
180
+ <image width="#{width}" height="#{height}" xlink:href="#{href}"/>
150
181
  </svg></body>
151
182
  </html>).to_ios
152
- # GitDen expects a cover.xhtml, so add it to the spine
183
+ # Gitden expects a cover.xhtml, so add it to the spine
153
184
  spine_builder.file 'cover.xhtml' => content
185
+ assigned_id = (spine_builder.instance_variable_get :@last_defined_item).item.id
154
186
  spine_builder.id 'cover'
155
187
  # clearly a deficiency of gepub that it does not match the id correctly
156
- book.spine.itemref_by_id['item_cover1'].idref = 'cover'
188
+ # FIXME can we move this hack elsewhere?
189
+ @book.spine.itemref_by_id[assigned_id].idref = 'cover'
190
+ nil
157
191
  end
158
192
 
159
193
  def add_images_from_front_matter
160
- if ::File.exist? 'front-matter.html'
161
- ::File.read('front-matter.html').scan(/<img src="(.+?)"/) do
162
- resources do
163
- file $1
164
- end
194
+ (::File.read 'front-matter.html').scan ImgSrcScanRx do
195
+ resources do
196
+ file $1
165
197
  end
166
- end
198
+ end if ::File.file? 'front-matter.html'
199
+ nil
167
200
  end
168
201
 
169
- def add_front_matter_page doc, spine_builder, builder, format
170
- if ::File.exist? 'front-matter.html'
171
- spine_builder.file 'front-matter.html' => (builder.postprocess_xhtml_file 'front-matter.html', format)
172
- (spine_builder.instance_variable_get :@last_defined_item).properties << 'svg'
202
+ def add_front_matter_page doc, spine_builder
203
+ if ::File.file? 'front-matter.html'
204
+ front_matter_content = ::File.read 'front-matter.html'
205
+ spine_builder.file 'front-matter.xhtml' => (postprocess_xhtml front_matter_content, @format)
206
+ unless (spine_builder.property? 'svg') || SvgImgSniffRx !~ front_matter_content
207
+ spine_builder.add_property 'svg'
208
+ end
173
209
  end
210
+ nil
174
211
  end
175
212
 
176
- # FIXME don't add same image more than once
177
213
  # FIXME add inline images
178
214
  def add_content_images doc, images
179
215
  docimagesdir = (doc.attr 'imagesdir', '.').chomp '/'
180
216
  docimagesdir = (docimagesdir == '.' ? nil : %(#{docimagesdir}/))
181
217
 
182
- workdir = doc.attr 'docdir', '.'
183
- workdir = '.' if workdir.empty?
218
+ workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
184
219
  resources workdir: workdir do
185
220
  images.each do |image|
186
221
  imagesdir = (image.document.attr 'imagesdir', '.').chomp '/'
187
222
  imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
188
223
  image_path = %(#{imagesdir}#{image.attr 'target'})
189
224
  if image_path.start_with? %(#{docimagesdir}jacket/cover.)
190
- warn %(The image path #{image_path} is reserved for the cover artwork. Ignoring conflicting image from content.)
225
+ warn %(asciidoctor: WARNING: image path is reserved for cover artwork: #{image_path}; skipping image found in content)
191
226
  elsif ::File.readable? image_path
192
227
  file image_path
193
228
  else
194
- warn %(Image not found or not readable: #{image_path})
229
+ warn %(asciidoctor: ERROR: image not found or not readable: #{image_path})
195
230
  end
196
231
  end
197
232
  end
233
+ nil
198
234
  end
199
235
 
200
236
  def add_profile_images doc, usernames
201
- spine = @spine
202
237
  imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
203
238
  imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
204
239
 
@@ -207,15 +242,14 @@ body > svg {
207
242
  file %(#{imagesdir}headshots/default.jpg) => ::File.join(DATA_DIR, 'images/default-headshot.jpg')
208
243
  end
209
244
 
210
- workdir = doc.attr 'docdir', '.'
211
- workdir = '.' if workdir.empty?
245
+ workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
212
246
  resources do
213
247
  usernames.each do |username|
214
248
  avatar = %(#{imagesdir}avatars/#{username}.jpg)
215
249
  if ::File.readable?(resolved_avatar = ::File.join(workdir, avatar))
216
250
  file avatar => resolved_avatar
217
251
  else
218
- warn %(Avatar #{avatar} not found or readable. Falling back to default avatar for #{username}.)
252
+ warn %(asciidoctor: ERROR: avatar for #{username} not found or readable: #{avatar}; falling back to default avatar)
219
253
  file avatar => ::File.join(DATA_DIR, 'images/default-avatar.jpg')
220
254
  end
221
255
 
@@ -223,55 +257,139 @@ body > svg {
223
257
  if ::File.readable?(resolved_headshot = ::File.join(workdir, headshot))
224
258
  file headshot => resolved_headshot
225
259
  elsif doc.attr? 'builder', 'editions'
226
- warn %(Headshot #{headshot} not found or readable. Falling back to default headshot for #{username}.)
260
+ warn %(asciidoctor: ERROR: headshot for #{username} not found or readable: #{headshot}; falling back to default headshot)
227
261
  file headshot => ::File.join(DATA_DIR, 'images/default-headshot.jpg')
228
262
  end
229
263
  end
230
- =begin
231
- spine.each do |item|
232
- username = (item.attr 'username') || 'default'
233
- avatar_target = %(#{imagesdir}avatars/#{username}.jpg)
234
- if ::File.readable?(avatar = %(#{item.attr 'docname'}/avatar.jpg))
235
- file avatar_target => avatar
236
- else
237
- warn %(Avatar #{avatar} not found or not readable. Falling back to default avatar for #{username}.)
238
- ::Dir.chdir DATA_DIR do
239
- file avatar_target => %(images/default-avatar.jpg)
240
- end
241
- end
242
- if ::File.readable? (headshot = %(#{item.attr 'docname'}/headshot.jpg))
243
- file headshot
244
- # TODO default headshot?
245
- end
246
- end
247
- =end
248
264
  end
265
+ nil
249
266
  end
250
267
 
251
268
  def add_content doc
252
- builder = self
253
- spine = @spine
254
- format = @format
255
- workdir = doc.attr 'docdir', '.'
256
- workdir = '.' if workdir.empty?
269
+ builder, spine, format = self, @spine, @format
270
+ workdir = (doc.attr 'docdir').nil_or_empty? ? '.' : workdir
257
271
  resources workdir: workdir do
272
+ extend GepubResourceBuilderMixin
258
273
  builder.add_images_from_front_matter
259
- # QUESTION should we move navigation_document to the Packager class? seems to make sense
260
- #nav 'nav.xhtml' => (builder.postprocess_xhtml doc.converter.navigation_document(doc, spine), format)
261
- nav 'nav.xhtml' => (builder.postprocess_xhtml ::Asciidoctor::Converter::Factory.default.create('epub3-xhtml5').navigation_document(doc, spine), format)
274
+ builder.add_nav_doc doc, self, spine, format
275
+ builder.add_ncx_doc doc, self, spine
262
276
  ordered do
263
- builder.add_cover_page doc, self, @book unless format == :kf8
264
- builder.add_front_matter_page doc, self, builder, format
277
+ builder.add_cover_page doc, self, @book.manifest unless format == :kf8
278
+ builder.add_front_matter_page doc, self
265
279
  spine.each_with_index do |item, i|
266
- content_path = %(#{item.id || (item.attr 'docname')}.xhtml)
267
- file content_path => (builder.postprocess_xhtml item.convert, format)
268
- # NOTE heading for ePub2 navigation file; toc.ncx requires headings to be plain text
269
- heading builder.sanitized_doctitle(item)
270
- @last_defined_item.properties << 'svg' if ((item.attr 'epub-properties') || []).include? 'svg'
280
+ file %(#{item.id || (item.attr 'docname')}.xhtml) => (builder.postprocess_xhtml item.convert, format)
281
+ add_property 'svg' if ((item.attr 'epub-properties') || []).include? 'svg'
282
+ # QUESTION reenable?
271
283
  #linear 'yes' if i == 0
272
284
  end
273
285
  end
274
286
  end
287
+ nil
288
+ end
289
+
290
+ def add_nav_doc doc, spine_builder, spine, format
291
+ spine_builder.nav 'nav.xhtml' => (postprocess_xhtml nav_doc(doc, spine), format)
292
+ spine_builder.id 'nav'
293
+ nil
294
+ end
295
+
296
+ # TODO aggregate authors of spine document into authors attribute(s) on main document
297
+ def nav_doc doc, spine
298
+ lines = [%(<!DOCTYPE html>
299
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="#{lang = (doc.attr 'lang', 'en')}" lang="#{lang}">
300
+ <head>
301
+ <meta charset="UTF-8"/>
302
+ <title>#{sanitize_doctitle_xml doc, :cdata}</title>
303
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
304
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
305
+ </head>
306
+ <body>
307
+ <h1>#{sanitize_doctitle_xml doc, :pcdata}</h1>
308
+ <nav epub:type="toc" id="toc">
309
+ <h2>#{doc.attr 'toc-title'}</h2>)]
310
+ lines << (nav_level spine, [(doc.attr 'toclevels', 1).to_i, 0].max)
311
+ lines << %(</nav>
312
+ </body>
313
+ </html>)
314
+ lines * EOL
315
+ end
316
+
317
+ def nav_level items, depth, state = {}
318
+ lines = []
319
+ lines << '<ol>'
320
+ items.each do |item|
321
+ #index = (state[:index] = (state.fetch :index, 0) + 1)
322
+ if item.context == :document
323
+ # NOTE we sanitize the chapter titles because we use formatting to control layout
324
+ item_label = sanitize_doctitle_xml item, :cdata
325
+ item_href = (state[:content_doc_href] = %(#{item.id || (item.attr 'docname')}.xhtml))
326
+ else
327
+ item_label = sanitize_xml item.title, :pcdata
328
+ item_href = %(#{state[:content_doc_href]}##{item.id})
329
+ end
330
+ lines << %(<li><a href="#{item_href}">#{item_label}</a>)
331
+ unless depth == 0 || (child_sections = item.sections).empty?
332
+ lines << (nav_level child_sections, depth - 1, state)
333
+ lines << '</li>'
334
+ else
335
+ lines[-1] = %(#{lines[-1]}</li>)
336
+ end
337
+ state.delete :content_doc_href if item.context == :document
338
+ end
339
+ lines << '</ol>'
340
+ lines * EOL
341
+ end
342
+
343
+ # NOTE gepub doesn't support building a ncx TOC with depth > 1, so do it ourselves
344
+ def add_ncx_doc doc, spine_builder, spine
345
+ spine_builder.file 'toc.ncx' => (ncx_doc doc, spine).to_ios
346
+ spine_builder.id 'ncx'
347
+ nil
348
+ end
349
+
350
+ def ncx_doc doc, spine
351
+ # TODO populate docAuthor element based on unique authors in work
352
+ lines = [%(<?xml version="1.0" encoding="utf-8"?>
353
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="#{doc.attr 'lang', 'en'}">
354
+ <head>
355
+ <meta name="dtb:uid" content="#{@book.identifier}"/>
356
+ %{depth}
357
+ <meta name="dtb:totalPageCount" content="0"/>
358
+ <meta name="dtb:maxPageNumber" content="0"/>
359
+ </head>
360
+ <docTitle><text>#{sanitize_doctitle_xml doc, :cdata}</text></docTitle>
361
+ <navMap>)]
362
+ lines << (ncx_level spine, [(doc.attr 'toclevels', 1).to_i, 0].max, state = {})
363
+ lines[0] = lines[0].sub '%{depth}', %(<meta name="dtb:depth" content="#{state[:max_depth]}"/>)
364
+ lines << %(</navMap>
365
+ </ncx>)
366
+ lines * EOL
367
+ end
368
+
369
+ def ncx_level items, depth, state = {}
370
+ lines = []
371
+ state[:max_depth] = (state.fetch :max_depth, 0) + 1
372
+ items.each do |item|
373
+ index = (state[:index] = (state.fetch :index, 0) + 1)
374
+ if item.context == :document
375
+ item_id = %(nav_#{index})
376
+ item_label = sanitize_doctitle_xml item, :cdata
377
+ item_href = (state[:content_doc_href] = %(#{item.id || (item.attr 'docname')}.xhtml))
378
+ else
379
+ item_id = %(nav_#{index})
380
+ item_label = sanitize_xml item.title, :cdata
381
+ item_href = %(#{state[:content_doc_href]}##{item.id})
382
+ end
383
+ lines << %(<navPoint id="#{item_id}" playOrder="#{index}">)
384
+ lines << %(<navLabel><text>#{item_label}</text></navLabel>)
385
+ lines << %(<content src="#{item_href}"/>)
386
+ unless depth == 0 || (child_sections = item.sections).empty?
387
+ lines << (ncx_level child_sections, depth - 1, state)
388
+ end
389
+ lines << %(</navPoint>)
390
+ state.delete :content_doc_href if item.context == :document
391
+ end
392
+ lines * EOL
275
393
  end
276
394
 
277
395
  def collect_keywords doc, spine
@@ -327,10 +445,23 @@ body > svg {
327
445
  end
328
446
  end
329
447
 
448
+ module GepubResourceBuilderMixin
449
+ # Add missing method to builder to add a property to last defined item
450
+ def add_property property
451
+ @last_defined_item.add_property property
452
+ end
453
+
454
+ # Add helper method to builder to check if property is set on last defined item
455
+ def property? property
456
+ (@last_defined_item['properties'] || []).include? property
457
+ end
458
+ end
459
+
330
460
  class Packager
331
461
  KINDLEGEN = ENV['KINDLEGEN'] || 'kindlegen'
332
462
  EPUBCHECK = ENV['EPUBCHECK'] || %(epubcheck#{::Gem.win_platform? ? '.bat' : '.sh'})
333
463
  EpubExtensionRx = /\.epub$/i
464
+ KindlegenCompression = ::Hash['0', '-c0', '1', '-c1', '2', '-c2', 'none', '-c0', 'standard', '-c1', 'huffdic', '-c2']
334
465
 
335
466
  def initialize spine_doc, spine, format = :epub3, options = {}
336
467
  @document = spine_doc
@@ -345,8 +476,8 @@ class Packager
345
476
  target = options[:target]
346
477
  dest = File.dirname target
347
478
 
348
- images = spine.map {|item| (item.find_by context: :image) || [] }.flatten
349
- usernames = spine.map {|item| item.attr 'username' }.compact.uniq
479
+ images = spine.map {|item| item.find_by context: :image }.compact.flatten
480
+ .uniq {|img| %(#{(img.document.attr 'imagesdir', '.').chomp '/'}/#{img.attr 'target'}) }
350
481
  # FIXME authors should be aggregated already on parent document
351
482
  authors = if doc.attr? 'authors'
352
483
  (doc.attr 'authors').split(GepubBuilderMixin::CsvDelimiterRx).concat(spine.map {|item| item.attr 'author' }).uniq
@@ -372,7 +503,8 @@ class Packager
372
503
  # replace with next line once the attributes argument is supported
373
504
  #unique_identifier doc.id, 'pub-id', 'uuid', 'scheme' => 'xsd:string'
374
505
 
375
- title sanitized_doctitle(doc)
506
+ # NOTE we must use :plain_text here since gepub reencodes
507
+ title(sanitize_doctitle_xml doc, :plain_text)
376
508
  id 'pub-title'
377
509
 
378
510
  # FIXME this logic needs some work
@@ -429,11 +561,14 @@ class Packager
429
561
  rights(doc.attr 'copyright')
430
562
  end
431
563
 
432
- #add_metadata 'ibooks:specified-fonts', true
564
+ #add_metadata 'ibooks:specified-fonts', true
433
565
 
434
566
  add_theme_assets doc
435
567
  add_cover_image doc
436
- add_profile_images doc, usernames
568
+ if (doc.attr 'publication-type', 'book') != 'book'
569
+ usernames = spine.map {|item| item.attr 'username' }.compact.uniq
570
+ add_profile_images doc, usernames
571
+ end
437
572
  # QUESTION move add_content_images to add_content method?
438
573
  add_content_images doc, images
439
574
  add_content doc
@@ -464,21 +599,22 @@ class Packager
464
599
 
465
600
  if fmt == :kf8
466
601
  # QUESTION shouldn't we validate this epub file too?
467
- distill_epub_to_mobi epub_file, target
602
+ distill_epub_to_mobi epub_file, target, options[:compress]
468
603
  elsif options[:validate]
469
604
  validate_epub epub_file
470
605
  end
471
606
  end
472
607
 
473
- # QUESTION how to enable the -c2 flag? (enables ~3-5% compression)
474
- def distill_epub_to_mobi epub_file, target
608
+ def distill_epub_to_mobi epub_file, target, compress
475
609
  kindlegen_cmd = KINDLEGEN
476
610
  unless ::File.executable? kindlegen_cmd
477
611
  require 'kindlegen' unless defined? ::Kindlegen
478
612
  kindlegen_cmd = ::Kindlegen.command
479
613
  end
480
614
  mobi_file = ::File.basename(target.sub EpubExtensionRx, '.mobi')
481
- ::Open3.popen2e(::Shellwords.join [kindlegen_cmd, '-dont_append_source', '-o', mobi_file, epub_file]) {|input, output, wait_thr|
615
+ compress_flag = KindlegenCompression[compress ? (compress.empty? ? '1' : compress.to_s) : '0']
616
+ cmd = [kindlegen_cmd, '-dont_append_source', compress_flag, '-o', mobi_file, epub_file].compact
617
+ ::Open3.popen2e(::Shellwords.join cmd) {|input, output, wait_thr|
482
618
  output.each {|line| puts line } unless $VERBOSE.nil?
483
619
  }
484
620
  puts %(Wrote MOBI to #{::File.join ::File.dirname(epub_file), mobi_file}) if $VERBOSE
@@ -487,7 +623,7 @@ class Packager
487
623
  def validate_epub epub_file
488
624
  epubcheck_cmd = EPUBCHECK
489
625
  unless ::File.executable? epubcheck_cmd
490
- epubcheck_cmd = ::Gem.bin_path 'epubcheck', 'epubcheck'
626
+ epubcheck_cmd = ::Gem.bin_path 'epubcheck', 'epubcheck'
491
627
  end
492
628
  # NOTE epubcheck gem doesn't support epubcheck command options; enable -quiet once supported
493
629
  ::Open3.popen2e(::Shellwords.join [epubcheck_cmd, epub_file]) {|input, output, wait_thr|