asciidoctor-epub3 1.5.0.alpha.12

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +12 -0
  3. data/CHANGELOG.adoc +199 -0
  4. data/Gemfile +16 -0
  5. data/LICENSE.adoc +25 -0
  6. data/NOTICE.adoc +54 -0
  7. data/README.adoc +1001 -0
  8. data/Rakefile +5 -0
  9. data/asciidoctor-epub3.gemspec +42 -0
  10. data/bin/adb-push-ebook +35 -0
  11. data/bin/asciidoctor-epub3 +30 -0
  12. data/data/fonts/assorted-icons.ttf +0 -0
  13. data/data/fonts/fontawesome-icons.ttf +0 -0
  14. data/data/fonts/mplus1mn-bold-ascii.ttf +0 -0
  15. data/data/fonts/mplus1mn-bolditalic-ascii.ttf +0 -0
  16. data/data/fonts/mplus1mn-italic-ascii.ttf +0 -0
  17. data/data/fonts/mplus1mn-regular-ascii-conums.ttf +0 -0
  18. data/data/fonts/mplus1p-bold-latin-cyrillic.ttf +0 -0
  19. data/data/fonts/mplus1p-bold-latin-ext.ttf +0 -0
  20. data/data/fonts/mplus1p-bold-latin.ttf +0 -0
  21. data/data/fonts/mplus1p-bold-multilingual.ttf +0 -0
  22. data/data/fonts/mplus1p-light-latin-cyrillic.ttf +0 -0
  23. data/data/fonts/mplus1p-light-latin-ext.ttf +0 -0
  24. data/data/fonts/mplus1p-light-latin.ttf +0 -0
  25. data/data/fonts/mplus1p-light-multilingual.ttf +0 -0
  26. data/data/fonts/mplus1p-regular-latin-cyrillic.ttf +0 -0
  27. data/data/fonts/mplus1p-regular-latin-ext.ttf +0 -0
  28. data/data/fonts/mplus1p-regular-latin.ttf +0 -0
  29. data/data/fonts/mplus1p-regular-multilingual.ttf +0 -0
  30. data/data/fonts/notoserif-bold-latin-cyrillic.ttf +0 -0
  31. data/data/fonts/notoserif-bold-latin-ext.ttf +0 -0
  32. data/data/fonts/notoserif-bold-latin.ttf +0 -0
  33. data/data/fonts/notoserif-bold-multilingual.ttf +0 -0
  34. data/data/fonts/notoserif-bolditalic-latin-cyrillic.ttf +0 -0
  35. data/data/fonts/notoserif-bolditalic-latin-ext.ttf +0 -0
  36. data/data/fonts/notoserif-bolditalic-latin.ttf +0 -0
  37. data/data/fonts/notoserif-bolditalic-multilingual.ttf +0 -0
  38. data/data/fonts/notoserif-italic-latin-cyrillic.ttf +0 -0
  39. data/data/fonts/notoserif-italic-latin-ext.ttf +0 -0
  40. data/data/fonts/notoserif-italic-latin.ttf +0 -0
  41. data/data/fonts/notoserif-italic-multilingual.ttf +0 -0
  42. data/data/fonts/notoserif-regular-latin-cyrillic.ttf +0 -0
  43. data/data/fonts/notoserif-regular-latin-ext.ttf +0 -0
  44. data/data/fonts/notoserif-regular-latin.ttf +0 -0
  45. data/data/fonts/notoserif-regular-multilingual.ttf +0 -0
  46. data/data/images/default-avatar.jpg +0 -0
  47. data/data/images/default-avatar.png +0 -0
  48. data/data/images/default-avatar.svg +67 -0
  49. data/data/images/default-cover.svg +53 -0
  50. data/data/images/default-headshot.jpg +0 -0
  51. data/data/images/default-headshot.png +0 -0
  52. data/data/styles/color-palette.css +28 -0
  53. data/data/styles/epub3-css3-only.css +226 -0
  54. data/data/styles/epub3-fonts.css +94 -0
  55. data/data/styles/epub3.css +1266 -0
  56. data/lib/asciidoctor-epub3.rb +11 -0
  57. data/lib/asciidoctor-epub3/converter.rb +951 -0
  58. data/lib/asciidoctor-epub3/ext.rb +4 -0
  59. data/lib/asciidoctor-epub3/ext/asciidoctor.rb +3 -0
  60. data/lib/asciidoctor-epub3/ext/asciidoctor/logging_shim.rb +33 -0
  61. data/lib/asciidoctor-epub3/ext/core.rb +3 -0
  62. data/lib/asciidoctor-epub3/ext/core/string.rb +9 -0
  63. data/lib/asciidoctor-epub3/font_icon_map.rb +378 -0
  64. data/lib/asciidoctor-epub3/packager.rb +722 -0
  65. data/lib/asciidoctor-epub3/spine_item_processor.rb +92 -0
  66. data/lib/asciidoctor-epub3/version.rb +7 -0
  67. metadata +237 -0
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ext/asciidoctor'
4
+ require_relative 'ext/core'
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'asciidoctor/logging_shim' unless defined? Asciidoctor::Logging
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module Logging
5
+ class StubLogger
6
+ class << self
7
+ def debug message = nil
8
+ puts %(asciidoctor: DEBUG: #{message || (block_given? ? yield : '???')}) if $VERBOSE
9
+ end
10
+
11
+ def info message = nil
12
+ puts %(asciidoctor: INFO: #{message || (block_given? ? yield : '???')}) if $VERBOSE
13
+ end
14
+
15
+ def warn message = nil
16
+ ::Kernel.warn %(asciidoctor: WARNING: #{message || (block_given? ? yield : '???')})
17
+ end
18
+
19
+ def error message = nil
20
+ ::Kernel.warn %(asciidoctor: ERROR: #{message || (block_given? ? yield : '???')})
21
+ end
22
+
23
+ def fatal message = nil
24
+ ::Kernel.warn %(asciidoctor: FATAL: #{message || (block_given? ? yield : '???')})
25
+ end
26
+ end
27
+ end
28
+
29
+ def logger
30
+ StubLogger
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'core/string'
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio' unless defined? StringIO
4
+
5
+ class String
6
+ def to_ios
7
+ StringIO.new self
8
+ end unless method_defined? :to_ios
9
+ end
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asciidoctor
4
+ module Epub3
5
+ # Map of Font Awesome icon names to unicode characters
6
+ FontIconMap = {
7
+ glass: '\f000',
8
+ music: '\f001',
9
+ search: '\f002',
10
+ envelope_o: '\f003',
11
+ heart: '\f004',
12
+ star: '\f005',
13
+ star_o: '\f006',
14
+ user: '\f007',
15
+ film: '\f008',
16
+ th_large: '\f009',
17
+ th: '\f00a',
18
+ th_list: '\f00b',
19
+ check: '\f00c',
20
+ times: '\f00d',
21
+ search_plus: '\f00e',
22
+ search_minus: '\f010',
23
+ power_off: '\f011',
24
+ signal: '\f012',
25
+ cog: '\f013',
26
+ trash_o: '\f014',
27
+ home: '\f015',
28
+ file_o: '\f016',
29
+ clock_o: '\f017',
30
+ road: '\f018',
31
+ download: '\f019',
32
+ arrow_circle_o_down: '\f01a',
33
+ arrow_circle_o_up: '\f01b',
34
+ inbox: '\f01c',
35
+ play_circle_o: '\f01d',
36
+ repeat: '\f01e',
37
+ refresh: '\f021',
38
+ list_alt: '\f022',
39
+ lock: '\f023',
40
+ flag: '\f024',
41
+ headphones: '\f025',
42
+ volume_off: '\f026',
43
+ volume_down: '\f027',
44
+ volume_up: '\f028',
45
+ qrcode: '\f029',
46
+ barcode: '\f02a',
47
+ tag: '\f02b',
48
+ tags: '\f02c',
49
+ book: '\f02d',
50
+ bookmark: '\f02e',
51
+ print: '\f02f',
52
+ camera: '\f030',
53
+ font: '\f031',
54
+ bold: '\f032',
55
+ italic: '\f033',
56
+ text_height: '\f034',
57
+ text_width: '\f035',
58
+ align_left: '\f036',
59
+ align_center: '\f037',
60
+ align_right: '\f038',
61
+ align_justify: '\f039',
62
+ list: '\f03a',
63
+ outdent: '\f03b',
64
+ indent: '\f03c',
65
+ video_camera: '\f03d',
66
+ picture_o: '\f03e',
67
+ pencil: '\f040',
68
+ map_marker: '\f041',
69
+ adjust: '\f042',
70
+ tint: '\f043',
71
+ pencil_square_o: '\f044',
72
+ share_square_o: '\f045',
73
+ check_square_o: '\f046',
74
+ arrows: '\f047',
75
+ step_backward: '\f048',
76
+ fast_backward: '\f049',
77
+ backward: '\f04a',
78
+ play: '\f04b',
79
+ pause: '\f04c',
80
+ stop: '\f04d',
81
+ forward: '\f04e',
82
+ fast_forward: '\f050',
83
+ step_forward: '\f051',
84
+ eject: '\f052',
85
+ chevron_left: '\f053',
86
+ chevron_right: '\f054',
87
+ plus_circle: '\f055',
88
+ minus_circle: '\f056',
89
+ times_circle: '\f057',
90
+ check_circle: '\f058',
91
+ question_circle: '\f059',
92
+ info_circle: '\f05a',
93
+ crosshairs: '\f05b',
94
+ times_circle_o: '\f05c',
95
+ check_circle_o: '\f05d',
96
+ ban: '\f05e',
97
+ arrow_left: '\f060',
98
+ arrow_right: '\f061',
99
+ arrow_up: '\f062',
100
+ arrow_down: '\f063',
101
+ share: '\f064',
102
+ expand: '\f065',
103
+ compress: '\f066',
104
+ plus: '\f067',
105
+ minus: '\f068',
106
+ asterisk: '\f069',
107
+ exclamation_circle: '\f06a',
108
+ gift: '\f06b',
109
+ leaf: '\f06c',
110
+ fire: '\f06d',
111
+ eye: '\f06e',
112
+ eye_slash: '\f070',
113
+ exclamation_triangle: '\f071',
114
+ plane: '\f072',
115
+ calendar: '\f073',
116
+ random: '\f074',
117
+ comment: '\f075',
118
+ magnet: '\f076',
119
+ chevron_up: '\f077',
120
+ chevron_down: '\f078',
121
+ retweet: '\f079',
122
+ shopping_cart: '\f07a',
123
+ folder: '\f07b',
124
+ folder_open: '\f07c',
125
+ arrows_v: '\f07d',
126
+ arrows_h: '\f07e',
127
+ bar_chart_o: '\f080',
128
+ twitter_square: '\f081',
129
+ facebook_square: '\f082',
130
+ camera_retro: '\f083',
131
+ key: '\f084',
132
+ cogs: '\f085',
133
+ comments: '\f086',
134
+ thumbs_o_up: '\f087',
135
+ thumbs_o_down: '\f088',
136
+ star_half: '\f089',
137
+ heart_o: '\f08a',
138
+ sign_out: '\f08b',
139
+ linkedin_square: '\f08c',
140
+ thumb_tack: '\f08d',
141
+ external_link: '\f08e',
142
+ sign_in: '\f090',
143
+ trophy: '\f091',
144
+ github_square: '\f092',
145
+ upload: '\f093',
146
+ lemon_o: '\f094',
147
+ phone: '\f095',
148
+ square_o: '\f096',
149
+ bookmark_o: '\f097',
150
+ phone_square: '\f098',
151
+ twitter: '\f099',
152
+ facebook: '\f09a',
153
+ github: '\f09b',
154
+ unlock: '\f09c',
155
+ credit_card: '\f09d',
156
+ rss: '\f09e',
157
+ hdd_o: '\f0a0',
158
+ bullhorn: '\f0a1',
159
+ bell: '\f0f3',
160
+ certificate: '\f0a3',
161
+ hand_o_right: '\f0a4',
162
+ hand_o_left: '\f0a5',
163
+ hand_o_up: '\f0a6',
164
+ hand_o_down: '\f0a7',
165
+ arrow_circle_left: '\f0a8',
166
+ arrow_circle_right: '\f0a9',
167
+ arrow_circle_up: '\f0aa',
168
+ arrow_circle_down: '\f0ab',
169
+ globe: '\f0ac',
170
+ wrench: '\f0ad',
171
+ tasks: '\f0ae',
172
+ filter: '\f0b0',
173
+ briefcase: '\f0b1',
174
+ arrows_alt: '\f0b2',
175
+ users: '\f0c0',
176
+ link: '\f0c1',
177
+ cloud: '\f0c2',
178
+ flask: '\f0c3',
179
+ scissors: '\f0c4',
180
+ files_o: '\f0c5',
181
+ paperclip: '\f0c6',
182
+ floppy_o: '\f0c7',
183
+ square: '\f0c8',
184
+ bars: '\f0c9',
185
+ list_ul: '\f0ca',
186
+ list_ol: '\f0cb',
187
+ strikethrough: '\f0cc',
188
+ underline: '\f0cd',
189
+ table: '\f0ce',
190
+ magic: '\f0d0',
191
+ truck: '\f0d1',
192
+ pinterest: '\f0d2',
193
+ pinterest_square: '\f0d3',
194
+ google_plus_square: '\f0d4',
195
+ google_plus: '\f0d5',
196
+ money: '\f0d6',
197
+ caret_down: '\f0d7',
198
+ caret_up: '\f0d8',
199
+ caret_left: '\f0d9',
200
+ caret_right: '\f0da',
201
+ columns: '\f0db',
202
+ sort: '\f0dc',
203
+ sort_asc: '\f0dd',
204
+ sort_desc: '\f0de',
205
+ envelope: '\f0e0',
206
+ linkedin: '\f0e1',
207
+ undo: '\f0e2',
208
+ gavel: '\f0e3',
209
+ tachometer: '\f0e4',
210
+ comment_o: '\f0e5',
211
+ comments_o: '\f0e6',
212
+ bolt: '\f0e7',
213
+ sitemap: '\f0e8',
214
+ umbrella: '\f0e9',
215
+ clipboard: '\f0ea',
216
+ lightbulb_o: '\f0eb',
217
+ exchange: '\f0ec',
218
+ cloud_download: '\f0ed',
219
+ cloud_upload: '\f0ee',
220
+ user_md: '\f0f0',
221
+ stethoscope: '\f0f1',
222
+ suitcase: '\f0f2',
223
+ bell_o: '\f0a2',
224
+ coffee: '\f0f4',
225
+ cutlery: '\f0f5',
226
+ file_text_o: '\f0f6',
227
+ building_o: '\f0f7',
228
+ hospital_o: '\f0f8',
229
+ ambulance: '\f0f9',
230
+ medkit: '\f0fa',
231
+ fighter_jet: '\f0fb',
232
+ beer: '\f0fc',
233
+ h_square: '\f0fd',
234
+ plus_square: '\f0fe',
235
+ angle_double_left: '\f100',
236
+ angle_double_right: '\f101',
237
+ angle_double_up: '\f102',
238
+ angle_double_down: '\f103',
239
+ angle_left: '\f104',
240
+ angle_right: '\f105',
241
+ angle_up: '\f106',
242
+ angle_down: '\f107',
243
+ desktop: '\f108',
244
+ laptop: '\f109',
245
+ tablet: '\f10a',
246
+ mobile: '\f10b',
247
+ circle_o: '\f10c',
248
+ quote_left: '\f10d',
249
+ quote_right: '\f10e',
250
+ spinner: '\f110',
251
+ circle: '\f111',
252
+ reply: '\f112',
253
+ github_alt: '\f113',
254
+ folder_o: '\f114',
255
+ folder_open_o: '\f115',
256
+ smile_o: '\f118',
257
+ frown_o: '\f119',
258
+ meh_o: '\f11a',
259
+ gamepad: '\f11b',
260
+ keyboard_o: '\f11c',
261
+ flag_o: '\f11d',
262
+ flag_checkered: '\f11e',
263
+ terminal: '\f120',
264
+ code: '\f121',
265
+ reply_all: '\f122',
266
+ mail_reply_all: '\f122',
267
+ star_half_o: '\f123',
268
+ location_arrow: '\f124',
269
+ crop: '\f125',
270
+ code_fork: '\f126',
271
+ chain_broken: '\f127',
272
+ question: '\f128',
273
+ info: '\f129',
274
+ exclamation: '\f12a',
275
+ superscript: '\f12b',
276
+ subscript: '\f12c',
277
+ eraser: '\f12d',
278
+ puzzle_piece: '\f12e',
279
+ microphone: '\f130',
280
+ microphone_slash: '\f131',
281
+ shield: '\f132',
282
+ calendar_o: '\f133',
283
+ fire_extinguisher: '\f134',
284
+ rocket: '\f135',
285
+ maxcdn: '\f136',
286
+ chevron_circle_left: '\f137',
287
+ chevron_circle_right: '\f138',
288
+ chevron_circle_up: '\f139',
289
+ chevron_circle_down: '\f13a',
290
+ html5: '\f13b',
291
+ css3: '\f13c',
292
+ anchor: '\f13d',
293
+ unlock_alt: '\f13e',
294
+ bullseye: '\f140',
295
+ ellipsis_h: '\f141',
296
+ ellipsis_v: '\f142',
297
+ rss_square: '\f143',
298
+ play_circle: '\f144',
299
+ ticket: '\f145',
300
+ minus_square: '\f146',
301
+ minus_square_o: '\f147',
302
+ level_up: '\f148',
303
+ level_down: '\f149',
304
+ check_square: '\f14a',
305
+ pencil_square: '\f14b',
306
+ external_link_square: '\f14c',
307
+ share_square: '\f14d',
308
+ compass: '\f14e',
309
+ caret_square_o_down: '\f150',
310
+ caret_square_o_up: '\f151',
311
+ caret_square_o_right: '\f152',
312
+ eur: '\f153',
313
+ gbp: '\f154',
314
+ usd: '\f155',
315
+ inr: '\f156',
316
+ jpy: '\f157',
317
+ rub: '\f158',
318
+ krw: '\f159',
319
+ btc: '\f15a',
320
+ file: '\f15b',
321
+ file_text: '\f15c',
322
+ sort_alpha_asc: '\f15d',
323
+ sort_alpha_desc: '\f15e',
324
+ sort_amount_asc: '\f160',
325
+ sort_amount_desc: '\f161',
326
+ sort_numeric_asc: '\f162',
327
+ sort_numeric_desc: '\f163',
328
+ thumbs_up: '\f164',
329
+ thumbs_down: '\f165',
330
+ youtube_square: '\f166',
331
+ youtube: '\f167',
332
+ xing: '\f168',
333
+ xing_square: '\f169',
334
+ youtube_play: '\f16a',
335
+ dropbox: '\f16b',
336
+ stack_overflow: '\f16c',
337
+ instagram: '\f16d',
338
+ flickr: '\f16e',
339
+ adn: '\f170',
340
+ bitbucket: '\f171',
341
+ bitbucket_square: '\f172',
342
+ tumblr: '\f173',
343
+ tumblr_square: '\f174',
344
+ long_arrow_down: '\f175',
345
+ long_arrow_up: '\f176',
346
+ long_arrow_left: '\f177',
347
+ long_arrow_right: '\f178',
348
+ apple: '\f179',
349
+ windows: '\f17a',
350
+ android: '\f17b',
351
+ linux: '\f17c',
352
+ dribbble: '\f17d',
353
+ skype: '\f17e',
354
+ foursquare: '\f180',
355
+ trello: '\f181',
356
+ female: '\f182',
357
+ male: '\f183',
358
+ gittip: '\f184',
359
+ sun_o: '\f185',
360
+ moon_o: '\f186',
361
+ archive: '\f187',
362
+ bug: '\f188',
363
+ vk: '\f189',
364
+ weibo: '\f18a',
365
+ renren: '\f18b',
366
+ pagelines: '\f18c',
367
+ stack_exchange: '\f18d',
368
+ arrow_circle_o_right: '\f18e',
369
+ arrow_circle_o_left: '\f190',
370
+ caret_square_o_left: '\f191',
371
+ dot_circle_o: '\f192',
372
+ wheelchair: '\f193',
373
+ vimeo_square: '\f194',
374
+ try: '\f195',
375
+ plus_square_o: '\f196',
376
+ }
377
+ end
378
+ end
@@ -0,0 +1,722 @@
1
+ # frozen_string_literal: true
2
+
3
+ autoload :FileUtils, 'fileutils'
4
+ autoload :Open3, 'open3'
5
+
6
+ module Asciidoctor
7
+ module Epub3
8
+ module GepubBuilderMixin
9
+ include ::Asciidoctor::Logging
10
+ DATA_DIR = ::File.expand_path ::File.join(__dir__, '..', '..', 'data')
11
+ SAMPLES_DIR = ::File.join DATA_DIR, 'samples'
12
+ LF = ?\n
13
+ CharEntityRx = ContentConverter::CharEntityRx
14
+ XmlElementRx = ContentConverter::XmlElementRx
15
+ FromHtmlSpecialCharsMap = ContentConverter::FromHtmlSpecialCharsMap
16
+ FromHtmlSpecialCharsRx = ContentConverter::FromHtmlSpecialCharsRx
17
+ CsvDelimiterRx = /\s*,\s*/
18
+ ImageMacroRx = /^image::?(.*?)\[(.*?)\]$/
19
+ ImgSrcScanRx = /<img src="(.+?)"/
20
+ SvgImgSniffRx = /<img src=".+?\.svg"/
21
+
22
+ attr_reader :book, :format, :spine
23
+
24
+ # FIXME: move to Asciidoctor::Helpers
25
+ def sanitize_doctitle_xml doc, content_spec
26
+ doctitle = doc.header? ? doc.doctitle : (doc.attr 'untitled-label')
27
+ sanitize_xml doctitle, content_spec
28
+ end
29
+
30
+ # FIXME: move to Asciidoctor::Helpers
31
+ def sanitize_xml content, content_spec
32
+ if content_spec != :pcdata && (content.include? '<')
33
+ if (content = (content.gsub XmlElementRx, '').strip).include? ' '
34
+ content = content.tr_s ' ', ' '
35
+ end
36
+ end
37
+
38
+ case content_spec
39
+ when :attribute_cdata
40
+ content = content.gsub '"', '&quot;' if content.include? '"'
41
+ when :cdata, :pcdata
42
+ # noop
43
+ when :plain_text
44
+ if content.include? ';'
45
+ content = content.gsub(CharEntityRx) { [$1.to_i].pack 'U*' } if content.include? '&#'
46
+ content = content.gsub FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap
47
+ end
48
+ else
49
+ raise ::ArgumentError, %(Unknown content spec: #{content_spec})
50
+ end
51
+ content
52
+ end
53
+
54
+ def add_theme_assets doc
55
+ builder = self
56
+ format = @format
57
+ workdir = if doc.attr? 'epub3-stylesdir'
58
+ stylesdir = doc.attr 'epub3-stylesdir'
59
+ # FIXME: make this work for Windows paths!!
60
+ if stylesdir.start_with? '/'
61
+ stylesdir
62
+ else
63
+ docdir = doc.attr 'docdir', '.'
64
+ docdir = '.' if docdir.empty?
65
+ ::File.join docdir, stylesdir
66
+ end
67
+ else
68
+ ::File.join DATA_DIR, 'styles'
69
+ end
70
+
71
+ # TODO: improve design/UX of custom theme functionality, including custom fonts
72
+ resources do
73
+ if format == :kf8
74
+ # NOTE add layer of indirection so Kindle Direct Publishing (KDP) doesn't strip font-related CSS rules
75
+ file 'styles/epub3.css' => '@import url("epub3-proxied.css");'.to_ios
76
+ file 'styles/epub3-css3-only.css' => '@import url("epub3-css3-only-proxied.css");'.to_ios
77
+ file 'styles/epub3-proxied.css' => (builder.postprocess_css_file ::File.join(workdir, 'epub3.css'), format)
78
+ file 'styles/epub3-css3-only-proxied.css' => (builder.postprocess_css_file ::File.join(workdir, 'epub3-css3-only.css'), format)
79
+ else
80
+ file 'styles/epub3.css' => (builder.postprocess_css_file ::File.join(workdir, 'epub3.css'), format)
81
+ file 'styles/epub3-css3-only.css' => (builder.postprocess_css_file ::File.join(workdir, 'epub3-css3-only.css'), format)
82
+ end
83
+ end
84
+
85
+ resources do
86
+ font_files, font_css = builder.select_fonts ::File.join(DATA_DIR, 'styles/epub3-fonts.css'), (doc.attr 'scripts', 'latin')
87
+ file 'styles/epub3-fonts.css' => font_css
88
+ unless font_files.empty?
89
+ # NOTE metadata property in oepbs package manifest doesn't work; must use proprietary iBooks file instead
90
+ #(@book.metadata.add_metadata 'meta', 'true')['property'] = 'ibooks:specified-fonts' unless format == :kf8
91
+ builder.optional_file 'META-INF/com.apple.ibooks.display-options.xml' => '<?xml version="1.0" encoding="UTF-8"?>
92
+ <display_options>
93
+ <platform name="*">
94
+ <option name="specified-fonts">true</option>
95
+ </platform>
96
+ </display_options>'.to_ios unless format == :kf8
97
+
98
+ # https://github.com/asciidoctor/asciidoctor-epub3/issues/120
99
+ #
100
+ # 'application/x-font-ttf' causes warnings in epubcheck 4.0.2,
101
+ # "non-standard font type". Discussion:
102
+ # https://www.mobileread.com/forums/showthread.php?t=231272
103
+ #
104
+ # 3.1 spec recommends 'application/font-sfnt', but epubcheck doesn't
105
+ # implement that yet (warnings). https://idpf.github.io/epub-cmt/v3/
106
+ #
107
+ # 3.0 spec recommends 'application/vnd.ms-opentype', this works without
108
+ # warnings.
109
+ # http://www.idpf.org/epub/30/spec/epub30-publications.html#sec-core-media-types
110
+ with_media_type 'application/vnd.ms-opentype' do
111
+ font_files.each do |font_file|
112
+ file font_file => ::File.join(DATA_DIR, font_file)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ nil
118
+ end
119
+
120
+ def add_cover_image doc
121
+ return if (image_path = doc.attr 'front-cover-image').nil?
122
+
123
+ imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
124
+ imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
125
+
126
+ image_attrs = {}
127
+ if (image_path.include? ':') && image_path =~ ImageMacroRx
128
+ logger.warn %(deprecated block macro syntax detected in front-cover-image attribute) if image_path.start_with? 'image::'
129
+ image_path = %(#{imagesdir}#{$1})
130
+ (::Asciidoctor::AttributeList.new $2).parse_into image_attrs, %w(alt width height) unless $2.empty?
131
+ end
132
+
133
+ workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
134
+ unless ::File.readable? ::File.join(workdir, image_path)
135
+ logger.error %(#{::File.basename doc.attr('docfile')}: front cover image not found or readable: #{::File.expand_path image_path, workdir})
136
+ return
137
+ end
138
+
139
+ unless !image_attrs.empty? && (width = image_attrs['width']) && (height = image_attrs['height'])
140
+ width, height = 1050, 1600
141
+ end
142
+
143
+ resources do
144
+ cover_image %(#{imagesdir}jacket/cover#{::File.extname image_path}) => (::File.join workdir, image_path)
145
+ @last_defined_item.tap do |last_item|
146
+ last_item['width'] = width
147
+ last_item['height'] = height
148
+ end
149
+ end
150
+ nil
151
+ end
152
+
153
+ # NOTE must be called within the ordered block
154
+ def add_cover_page doc, spine_builder, manifest
155
+ return if (cover_item_attrs = manifest.items['item_cover'].instance_variable_get :@attributes).nil?
156
+
157
+ href = cover_item_attrs['href']
158
+ # NOTE we only store width and height temporarily to pass through the values
159
+ width = cover_item_attrs.delete 'width'
160
+ height = cover_item_attrs.delete 'height'
161
+
162
+ # NOTE SVG wrapper maintains aspect ratio and confines image to view box
163
+ content = %(<!DOCTYPE html>
164
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
165
+ <head>
166
+ <meta charset="UTF-8"/>
167
+ <title>#{sanitize_doctitle_xml doc, :cdata}</title>
168
+ <style type="text/css">
169
+ @page {
170
+ margin: 0;
171
+ }
172
+ html {
173
+ margin: 0 !important;
174
+ padding: 0 !important;
175
+ }
176
+ body {
177
+ margin: 0;
178
+ padding: 0 !important;
179
+ text-align: center;
180
+ }
181
+ body > svg {
182
+ /* prevent bleed onto second page (removes descender space) */
183
+ display: block;
184
+ }
185
+ </style>
186
+ </head>
187
+ <body epub:type="cover"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
188
+ width="100%" height="100%" viewBox="0 0 #{width} #{height}" preserveAspectRatio="xMidYMid meet">
189
+ <image width="#{width}" height="#{height}" xlink:href="#{href}"/>
190
+ </svg></body>
191
+ </html>).to_ios
192
+ # Gitden expects a cover.xhtml, so add it to the spine
193
+ spine_builder.file 'cover.xhtml' => content
194
+ assigned_id = (spine_builder.instance_variable_get :@last_defined_item).item.id
195
+ spine_builder.id 'cover'
196
+ # clearly a deficiency of gepub that it does not match the id correctly
197
+ # FIXME can we move this hack elsewhere?
198
+ @book.spine.itemref_by_id[assigned_id].idref = 'cover'
199
+ nil
200
+ end
201
+
202
+ def add_images_from_front_matter
203
+ (::File.read 'front-matter.html').scan ImgSrcScanRx do
204
+ resources do
205
+ file $1
206
+ end
207
+ end if ::File.file? 'front-matter.html'
208
+ nil
209
+ end
210
+
211
+ def add_front_matter_page _doc, spine_builder
212
+ if ::File.file? 'front-matter.html'
213
+ front_matter_content = ::File.read 'front-matter.html'
214
+ spine_builder.file 'front-matter.xhtml' => (postprocess_xhtml front_matter_content, @format)
215
+ spine_builder.add_property 'svg' unless (spine_builder.property? 'svg') || SvgImgSniffRx !~ front_matter_content
216
+ end
217
+ nil
218
+ end
219
+
220
+ def add_content_images doc, images
221
+ docimagesdir = (doc.attr 'imagesdir', '.').chomp '/'
222
+ docimagesdir = (docimagesdir == '.' ? nil : %(#{docimagesdir}/))
223
+
224
+ self_logger = logger
225
+ workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
226
+ resources workdir: workdir do
227
+ images.each do |image|
228
+ if (image_path = image[:path]).start_with? %(#{docimagesdir}jacket/cover.)
229
+ self_logger.warn %(image path is reserved for cover artwork: #{image_path}; skipping image found in content)
230
+ elsif ::File.readable? image_path
231
+ file image_path
232
+ else
233
+ self_logger.error %(#{::File.basename image[:docfile]}: image not found or not readable: #{::File.expand_path image_path, workdir})
234
+ end
235
+ end
236
+ end
237
+ nil
238
+ end
239
+
240
+ def add_profile_images doc, usernames
241
+ imagesdir = (doc.attr 'imagesdir', '.').chomp '/'
242
+ imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
243
+
244
+ resources do
245
+ file %(#{imagesdir}avatars/default.jpg) => ::File.join(DATA_DIR, 'images/default-avatar.jpg')
246
+ file %(#{imagesdir}headshots/default.jpg) => ::File.join(DATA_DIR, 'images/default-headshot.jpg')
247
+ end
248
+
249
+ self_logger = logger
250
+ workdir = (workdir = doc.attr 'docdir').nil_or_empty? ? '.' : workdir
251
+ resources do
252
+ usernames.each do |username|
253
+ avatar = %(#{imagesdir}avatars/#{username}.jpg)
254
+ if ::File.readable? (resolved_avatar = (::File.join workdir, avatar))
255
+ file avatar => resolved_avatar
256
+ else
257
+ self_logger.error %(avatar for #{username} not found or readable: #{avatar}; falling back to default avatar)
258
+ file avatar => ::File.join(DATA_DIR, 'images/default-avatar.jpg')
259
+ end
260
+
261
+ headshot = %(#{imagesdir}headshots/#{username}.jpg)
262
+ if ::File.readable? (resolved_headshot = (::File.join workdir, headshot))
263
+ file headshot => resolved_headshot
264
+ elsif doc.attr? 'builder', 'editions'
265
+ self_logger.error %(headshot for #{username} not found or readable: #{headshot}; falling back to default headshot)
266
+ file headshot => ::File.join(DATA_DIR, 'images/default-headshot.jpg')
267
+ end
268
+ end
269
+ end
270
+ nil
271
+ end
272
+
273
+ def add_content doc
274
+ builder, spine, format, images = self, @spine, @format, {}
275
+ workdir = (doc.attr 'docdir').nil_or_empty? ? '.' : workdir
276
+ resources workdir: workdir do
277
+ extend GepubResourceBuilderMixin
278
+ builder.add_images_from_front_matter
279
+ builder.add_nav_doc doc, self, spine, format
280
+ builder.add_ncx_doc doc, self, spine
281
+ ordered do
282
+ builder.add_cover_page doc, self, @book.manifest unless format == :kf8
283
+ builder.add_front_matter_page doc, self
284
+ spine.each_with_index do |item, _i|
285
+ docfile = item.attr 'docfile'
286
+ imagesdir = (item.attr 'imagesdir', '.').chomp '/'
287
+ imagesdir = (imagesdir == '.' ? '' : %(#{imagesdir}/))
288
+ file %(#{item.id || (item.attr 'docname')}.xhtml) => (builder.postprocess_xhtml item.convert, format)
289
+ add_property 'svg' if ((item.attr 'epub-properties') || []).include? 'svg'
290
+ # QUESTION should we pass the document itself?
291
+ item.references[:images].each do |target|
292
+ images[image_path = %(#{imagesdir}#{target})] ||= { docfile: docfile, path: image_path }
293
+ end
294
+ # QUESTION reenable?
295
+ #linear 'yes' if i == 0
296
+ end
297
+ end
298
+ end
299
+ add_content_images doc, images.values
300
+ nil
301
+ end
302
+
303
+ def add_nav_doc doc, spine_builder, spine, format
304
+ spine_builder.nav 'nav.xhtml' => (postprocess_xhtml nav_doc(doc, spine), format)
305
+ spine_builder.id 'nav'
306
+ nil
307
+ end
308
+
309
+ # TODO: aggregate authors of spine document into authors attribute(s) on main document
310
+ def nav_doc doc, spine
311
+ lines = [%(<!DOCTYPE html>
312
+ <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}">
313
+ <head>
314
+ <meta charset="UTF-8"/>
315
+ <title>#{sanitize_doctitle_xml doc, :cdata}</title>
316
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
317
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
318
+ </head>
319
+ <body>
320
+ <h1>#{sanitize_doctitle_xml doc, :pcdata}</h1>
321
+ <nav epub:type="toc" id="toc">
322
+ <h2>#{doc.attr 'toc-title'}</h2>)]
323
+ lines << (nav_level spine, [(doc.attr 'toclevels', 1).to_i, 0].max)
324
+ lines << %(</nav>
325
+ </body>
326
+ </html>)
327
+ lines * LF
328
+ end
329
+
330
+ def nav_level items, depth, state = {}
331
+ lines = []
332
+ lines << '<ol>'
333
+ items.each do |item|
334
+ #index = (state[:index] = (state.fetch :index, 0) + 1)
335
+ if item.context == :document
336
+ # NOTE we sanitize the chapter titles because we use formatting to control layout
337
+ item_label = sanitize_doctitle_xml item, :cdata
338
+ item_href = (state[:content_doc_href] = %(#{item.id || (item.attr 'docname')}.xhtml))
339
+ else
340
+ item_label = sanitize_xml item.title, :pcdata
341
+ item_href = %(#{state[:content_doc_href]}##{item.id})
342
+ end
343
+ lines << %(<li><a href="#{item_href}">#{item_label}</a>)
344
+ if depth == 0 || (child_sections = item.sections).empty?
345
+ lines[-1] = %(#{lines[-1]}</li>)
346
+ else
347
+ lines << (nav_level child_sections, depth - 1, state)
348
+ lines << '</li>'
349
+ end
350
+ state.delete :content_doc_href if item.context == :document
351
+ end
352
+ lines << '</ol>'
353
+ lines * LF
354
+ end
355
+
356
+ # NOTE gepub doesn't support building a ncx TOC with depth > 1, so do it ourselves
357
+ def add_ncx_doc doc, spine_builder, spine
358
+ spine_builder.file 'toc.ncx' => (ncx_doc doc, spine).to_ios
359
+ spine_builder.id 'ncx'
360
+ nil
361
+ end
362
+
363
+ def ncx_doc doc, spine
364
+ # TODO: populate docAuthor element based on unique authors in work
365
+ lines = [%(<?xml version="1.0" encoding="utf-8"?>
366
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="#{doc.attr 'lang', 'en'}">
367
+ <head>
368
+ <meta name="dtb:uid" content="#{@book.identifier}"/>
369
+ %{depth}
370
+ <meta name="dtb:totalPageCount" content="0"/>
371
+ <meta name="dtb:maxPageNumber" content="0"/>
372
+ </head>
373
+ <docTitle><text>#{sanitize_doctitle_xml doc, :cdata}</text></docTitle>
374
+ <navMap>)]
375
+ lines << (ncx_level spine, [(doc.attr 'toclevels', 1).to_i, 0].max, state = {})
376
+ lines[0] = lines[0].sub '%{depth}', %(<meta name="dtb:depth" content="#{state[:max_depth]}"/>)
377
+ lines << %(</navMap>
378
+ </ncx>)
379
+ lines * LF
380
+ end
381
+
382
+ def ncx_level items, depth, state = {}
383
+ lines = []
384
+ state[:max_depth] = (state.fetch :max_depth, 0) + 1
385
+ items.each do |item|
386
+ index = (state[:index] = (state.fetch :index, 0) + 1)
387
+ item_id = %(nav_#{index})
388
+ if item.context == :document
389
+ item_label = sanitize_doctitle_xml item, :cdata
390
+ item_href = (state[:content_doc_href] = %(#{item.id || (item.attr 'docname')}.xhtml))
391
+ else
392
+ item_label = sanitize_xml item.title, :cdata
393
+ item_href = %(#{state[:content_doc_href]}##{item.id})
394
+ end
395
+ lines << %(<navPoint id="#{item_id}" playOrder="#{index}">)
396
+ lines << %(<navLabel><text>#{item_label}</text></navLabel>)
397
+ lines << %(<content src="#{item_href}"/>)
398
+ unless depth == 0 || (child_sections = item.sections).empty?
399
+ lines << (ncx_level child_sections, depth - 1, state)
400
+ end
401
+ lines << %(</navPoint>)
402
+ state.delete :content_doc_href if item.context == :document
403
+ end
404
+ lines * LF
405
+ end
406
+
407
+ def collect_keywords doc, spine
408
+ ([doc] + spine).map {|item|
409
+ if item.attr? 'keywords'
410
+ (item.attr 'keywords').split CsvDelimiterRx
411
+ else
412
+ []
413
+ end
414
+ }.flatten.uniq
415
+ end
416
+
417
+ # Swap fonts in CSS based on the value of the document attribute 'scripts',
418
+ # then return the list of fonts as well as the font CSS.
419
+ def select_fonts filename, scripts = 'latin'
420
+ font_css = ::File.read filename
421
+ font_css = font_css.gsub(/(?<=-)latin(?=\.ttf\))/, scripts) unless scripts == 'latin'
422
+
423
+ # match CSS font urls in the forms of:
424
+ # src: url(../fonts/notoserif-regular-latin.ttf);
425
+ # src: url(../fonts/notoserif-regular-latin.ttf) format("truetype");
426
+ font_list = font_css.scan(/url\(\.\.\/([^)]+\.ttf)\)/).flatten
427
+
428
+ [font_list, font_css.to_ios]
429
+ end
430
+
431
+ def postprocess_css_file filename, format
432
+ return filename unless format == :kf8
433
+ postprocess_css ::File.read(filename), format
434
+ end
435
+
436
+ def postprocess_css content, format
437
+ return content.to_ios unless format == :kf8
438
+ # TODO: convert regular expressions to constants
439
+ content
440
+ .gsub(/^ -webkit-column-break-.*\n/, '')
441
+ .gsub(/^ max-width: .*\n/, '')
442
+ .to_ios
443
+ end
444
+
445
+ def postprocess_xhtml_file filename, format
446
+ return filename unless format == :kf8
447
+ postprocess_xhtml ::File.read(filename), format
448
+ end
449
+
450
+ # NOTE Kindle requires that
451
+ # <meta charset="utf-8"/>
452
+ # be converted to
453
+ # <meta http-equiv="Content-Type" content="application/xml+xhtml; charset=UTF-8"/>
454
+ def postprocess_xhtml content, format
455
+ return content.to_ios unless format == :kf8
456
+ # TODO: convert regular expressions to constants
457
+ content
458
+ .gsub(/<meta charset="(.+?)"\/>/, '<meta http-equiv="Content-Type" content="application/xml+xhtml; charset=\1"/>')
459
+ .gsub(/<img([^>]+) style="width: (\d\d)%;"/, '<img\1 style="width: \2%; height: \2%;"')
460
+ .gsub(/<script type="text\/javascript">.*?<\/script>\n?/m, '')
461
+ .to_ios
462
+ end
463
+ end
464
+
465
+ module GepubResourceBuilderMixin
466
+ # Add missing method to builder to add a property to last defined item
467
+ def add_property property
468
+ @last_defined_item.add_property property
469
+ end
470
+
471
+ # Add helper method to builder to check if property is set on last defined item
472
+ def property? property
473
+ (@last_defined_item['properties'] || []).include? property
474
+ end
475
+ end
476
+
477
+ class Packager
478
+ include ::Asciidoctor::Logging
479
+
480
+ EpubExtensionRx = /\.epub$/i
481
+ KindlegenCompression = ::Hash['0', '-c0', '1', '-c1', '2', '-c2', 'none', '-c0', 'standard', '-c1', 'huffdic', '-c2']
482
+
483
+ def initialize spine_doc, spine, format = :epub3, _options = {}
484
+ @document = spine_doc
485
+ @spine = spine || []
486
+ @format = format
487
+ end
488
+
489
+ def package options = {}
490
+ doc = @document
491
+ spine = @spine
492
+ fmt = @format
493
+ target = options[:target]
494
+ dest = File.dirname target
495
+
496
+ # FIXME: authors should be aggregated already on parent document
497
+ if doc.attr? 'authors'
498
+ authors = (doc.attr 'authors').split(GepubBuilderMixin::CsvDelimiterRx).concat(spine.map {|item| item.attr 'author' }.compact).uniq
499
+ else
500
+ authors = []
501
+ end
502
+
503
+ builder = ::GEPUB::Builder.new do
504
+ extend GepubBuilderMixin
505
+ @document = doc
506
+ @spine = spine
507
+ @format = fmt
508
+ @book.epub_backward_compat = fmt != :kf8
509
+
510
+ language doc.attr('lang', 'en')
511
+ id 'pub-language'
512
+
513
+ if doc.attr? 'uuid'
514
+ unique_identifier doc.attr('uuid'), 'pub-identifier', 'uuid'
515
+ else
516
+ unique_identifier doc.id, 'pub-identifier', 'uuid'
517
+ end
518
+ # replace with next line once the attributes argument is supported
519
+ #unique_identifier doc.id, 'pub-id', 'uuid', 'scheme' => 'xsd:string'
520
+
521
+ # NOTE we must use :plain_text here since gepub reencodes
522
+ title sanitize_doctitle_xml(doc, :plain_text)
523
+ id 'pub-title'
524
+
525
+ # FIXME: this logic needs some work
526
+ if doc.attr? 'publisher'
527
+ publisher (publisher_name = (doc.attr 'publisher'))
528
+ # marc role: Book producer (see http://www.loc.gov/marc/relators/relaterm.html)
529
+ creator (doc.attr 'producer', publisher_name), 'bkp'
530
+ elsif doc.attr? 'producer'
531
+ # NOTE Use producer as both publisher and producer if publisher isn't specified
532
+ producer_name = doc.attr 'producer'
533
+ publisher producer_name
534
+ # marc role: Book producer (see http://www.loc.gov/marc/relators/relaterm.html)
535
+ creator producer_name, 'bkp'
536
+ elsif doc.attr? 'author'
537
+ # NOTE Use author as creator if both publisher or producer are absent
538
+ # marc role: Author (see http://www.loc.gov/marc/relators/relaterm.html)
539
+ creator doc.attr('author'), 'aut'
540
+ end
541
+
542
+ if doc.attr? 'creator'
543
+ # marc role: Creator (see http://www.loc.gov/marc/relators/relaterm.html)
544
+ creator doc.attr('creator'), 'cre'
545
+ else
546
+ # marc role: Manufacturer (see http://www.loc.gov/marc/relators/relaterm.html)
547
+ # QUESTION should this be bkp?
548
+ creator 'Asciidoctor', 'mfr'
549
+ end
550
+
551
+ # TODO: getting author list should be a method on Asciidoctor API
552
+ contributors(*authors)
553
+
554
+ if doc.attr? 'revdate'
555
+ begin
556
+ date doc.attr('revdate')
557
+ rescue ArgumentError => e
558
+ logger.error %(#{::File.basename doc.attr('docfile')}: failed to parse revdate: #{e}, using current time as a fallback)
559
+ date ::Time.now
560
+ end
561
+ else
562
+ date ::Time.now
563
+ end
564
+
565
+ description doc.attr('description') if doc.attr? 'description'
566
+
567
+ (collect_keywords doc, spine).each do |s|
568
+ subject s
569
+ end
570
+
571
+ source doc.attr('source') if doc.attr? 'source'
572
+
573
+ rights doc.attr('copyright') if doc.attr? 'copyright'
574
+
575
+ #add_metadata 'ibooks:specified-fonts', true
576
+
577
+ add_theme_assets doc
578
+ add_cover_image doc
579
+ if (doc.attr 'publication-type', 'book') != 'book'
580
+ usernames = spine.map {|item| item.attr 'username' }.compact.uniq
581
+ add_profile_images doc, usernames
582
+ end
583
+ add_content doc
584
+ end
585
+
586
+ ::FileUtils.mkdir_p dest unless ::File.directory? dest
587
+
588
+ epub_file = fmt == :kf8 ? %(#{::Asciidoctor::Helpers.rootname target}-kf8.epub) : target
589
+ builder.generate_epub epub_file
590
+ logger.debug %(Wrote #{fmt.upcase} to #{epub_file})
591
+ if options[:extract]
592
+ extract_dir = epub_file.sub EpubExtensionRx, ''
593
+ ::FileUtils.remove_dir extract_dir if ::File.directory? extract_dir
594
+ ::Dir.mkdir extract_dir
595
+ ::Dir.chdir extract_dir do
596
+ ::Zip::File.open epub_file do |entries|
597
+ entries.each do |entry|
598
+ next unless entry.file?
599
+ unless (entry_dir = ::File.dirname entry.name) == '.' || (::File.directory? entry_dir)
600
+ ::FileUtils.mkdir_p entry_dir
601
+ end
602
+ entry.extract
603
+ end
604
+ end
605
+ end
606
+ logger.debug %(Extracted #{fmt.upcase} to #{extract_dir})
607
+ end
608
+
609
+ if fmt == :kf8
610
+ # QUESTION shouldn't we validate this epub file too?
611
+ distill_epub_to_mobi epub_file, target, options[:compress], options[:kindlegen_path]
612
+ elsif options[:validate]
613
+ validate_epub epub_file, options[:epubcheck_path]
614
+ end
615
+ end
616
+
617
+ def get_kindlegen_command kindlegen_path
618
+ unless kindlegen_path.nil?
619
+ logger.debug %(Using ebook-kindlegen-path attribute: #{kindlegen_path})
620
+ return [kindlegen_path]
621
+ end
622
+
623
+ unless (result = ENV['KINDLEGEN']).nil?
624
+ logger.debug %(Using KINDLEGEN env variable: #{result})
625
+ return [result]
626
+ end
627
+
628
+ begin
629
+ require 'kindlegen' unless defined? ::Kindlegen
630
+ result = ::Kindlegen.command.to_s
631
+ logger.debug %(Using KindleGen from gem: #{result})
632
+ [result]
633
+ rescue LoadError => e
634
+ logger.debug %(#{e}; Using KindleGen from PATH)
635
+ [%(kindlegen#{::Gem.win_platform? ? '.exe' : ''})]
636
+ end
637
+ end
638
+
639
+ def distill_epub_to_mobi epub_file, target, compress, kindlegen_path
640
+ mobi_file = ::File.basename target.sub(EpubExtensionRx, '.mobi')
641
+ compress_flag = KindlegenCompression[compress ? (compress.empty? ? '1' : compress.to_s) : '0']
642
+
643
+ argv = get_kindlegen_command(kindlegen_path) + ['-dont_append_source', compress_flag, '-o', mobi_file, epub_file].compact
644
+ begin
645
+ # This duplicates Kindlegen.run, but we want to override executable
646
+ out, err, res = Open3.capture3(*argv) do |r|
647
+ r.force_encoding 'UTF-8' if ::Gem.win_platform? && r.respond_to?(:force_encoding)
648
+ end
649
+ rescue Errno::ENOENT => e
650
+ raise 'Unable to run KindleGen. Either install the kindlegen gem or set KINDLEGEN environment variable with path to KindleGen executable', cause: e
651
+ end
652
+
653
+ out.each_line do |line|
654
+ logger.info line
655
+ end
656
+ err.each_line do |line|
657
+ log_line line
658
+ end
659
+
660
+ output_file = ::File.join ::File.dirname(epub_file), mobi_file
661
+ if res.success?
662
+ logger.debug %(Wrote MOBI to #{output_file})
663
+ else
664
+ logger.error %(kindlegen failed to write MOBI to #{output_file})
665
+ end
666
+ end
667
+
668
+ def get_epubcheck_command epubcheck_path
669
+ unless epubcheck_path.nil?
670
+ logger.debug %(Using ebook-epubcheck-path attribute: #{epubcheck_path})
671
+ return [epubcheck_path]
672
+ end
673
+
674
+ unless (result = ENV['EPUBCHECK']).nil?
675
+ logger.debug %(Using EPUBCHECK env variable: #{result})
676
+ return [result]
677
+ end
678
+
679
+ begin
680
+ result = ::Gem.bin_path 'epubcheck-ruby', 'epubcheck'
681
+ logger.debug %(Using EPUBCheck from gem: #{result})
682
+ [::Gem.ruby, result]
683
+ rescue ::Gem::Exception => e
684
+ logger.debug %(#{e}; Using EPUBCheck from PATH)
685
+ ['epubcheck']
686
+ end
687
+ end
688
+
689
+ def validate_epub epub_file, epubcheck_path
690
+ argv = get_epubcheck_command(epubcheck_path) + ['-w', epub_file]
691
+ begin
692
+ out, err, res = Open3.capture3(*argv)
693
+ rescue Errno::ENOENT => e
694
+ raise 'Unable to run EPUBCheck. Either install epubcheck-ruby gem or set EPUBCHECK environment variable with path to EPUBCheck executable', cause: e
695
+ end
696
+
697
+ out.each_line do |line|
698
+ logger.info line
699
+ end
700
+ err.each_line do |line|
701
+ log_line line
702
+ end
703
+
704
+ logger.error %(EPUB validation failed: #{epub_file}) unless res.success?
705
+ end
706
+
707
+ def log_line line
708
+ line = line.strip
709
+
710
+ if line =~ /^fatal/i
711
+ logger.fatal line
712
+ elsif line =~ /^error/i
713
+ logger.error line
714
+ elsif line =~ /^warning/i
715
+ logger.warn line
716
+ else
717
+ logger.info line
718
+ end
719
+ end
720
+ end
721
+ end
722
+ end