asciidoctor-epub3 1.0.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.adoc +22 -0
  3. data/NOTICE.adoc +53 -0
  4. data/README.adoc +744 -0
  5. data/Rakefile +78 -0
  6. data/bin/adb-push-ebook +25 -0
  7. data/bin/asciidoctor-epub3 +15 -0
  8. data/data/fonts/assorted-icons.ttf +0 -0
  9. data/data/fonts/fontawesome-icons.ttf +0 -0
  10. data/data/fonts/mplus1mn-bold-ascii.ttf +0 -0
  11. data/data/fonts/mplus1mn-bolditalic-ascii.ttf +0 -0
  12. data/data/fonts/mplus1mn-italic-ascii.ttf +0 -0
  13. data/data/fonts/mplus1mn-regular-ascii-conums.ttf +0 -0
  14. data/data/fonts/mplus1p-bold-latin-cyrillic.ttf +0 -0
  15. data/data/fonts/mplus1p-bold-latin-ext.ttf +0 -0
  16. data/data/fonts/mplus1p-bold-latin.ttf +0 -0
  17. data/data/fonts/mplus1p-bold-multilingual.ttf +0 -0
  18. data/data/fonts/mplus1p-light-latin-cyrillic.ttf +0 -0
  19. data/data/fonts/mplus1p-light-latin-ext.ttf +0 -0
  20. data/data/fonts/mplus1p-light-latin.ttf +0 -0
  21. data/data/fonts/mplus1p-light-multilingual.ttf +0 -0
  22. data/data/fonts/mplus1p-regular-latin-cyrillic.ttf +0 -0
  23. data/data/fonts/mplus1p-regular-latin-ext.ttf +0 -0
  24. data/data/fonts/mplus1p-regular-latin.ttf +0 -0
  25. data/data/fonts/mplus1p-regular-multilingual.ttf +0 -0
  26. data/data/fonts/notoserif-bold-latin-cyrillic.ttf +0 -0
  27. data/data/fonts/notoserif-bold-latin-ext.ttf +0 -0
  28. data/data/fonts/notoserif-bold-latin.ttf +0 -0
  29. data/data/fonts/notoserif-bold-multilingual.ttf +0 -0
  30. data/data/fonts/notoserif-bolditalic-latin-cyrillic.ttf +0 -0
  31. data/data/fonts/notoserif-bolditalic-latin-ext.ttf +0 -0
  32. data/data/fonts/notoserif-bolditalic-latin.ttf +0 -0
  33. data/data/fonts/notoserif-bolditalic-multilingual.ttf +0 -0
  34. data/data/fonts/notoserif-italic-latin-cyrillic.ttf +0 -0
  35. data/data/fonts/notoserif-italic-latin-ext.ttf +0 -0
  36. data/data/fonts/notoserif-italic-latin.ttf +0 -0
  37. data/data/fonts/notoserif-italic-multilingual.ttf +0 -0
  38. data/data/fonts/notoserif-regular-latin-cyrillic.ttf +0 -0
  39. data/data/fonts/notoserif-regular-latin-ext.ttf +0 -0
  40. data/data/fonts/notoserif-regular-latin.ttf +0 -0
  41. data/data/fonts/notoserif-regular-multilingual.ttf +0 -0
  42. data/data/images/default-avatar.jpg +0 -0
  43. data/data/images/default-avatar.png +0 -0
  44. data/data/images/default-avatar.svg +67 -0
  45. data/data/images/default-cover-large.png +0 -0
  46. data/data/images/default-cover.png +0 -0
  47. data/data/images/default-cover.svg +53 -0
  48. data/data/images/default-headshot.jpg +0 -0
  49. data/data/images/default-headshot.png +0 -0
  50. data/data/samples/asciidoctor-epub3-readme.adoc +744 -0
  51. data/data/samples/asciidoctor-js-extension.adoc +46 -0
  52. data/data/samples/asciidoctor-js-introduction.adoc +91 -0
  53. data/data/samples/i18n.adoc +161 -0
  54. data/data/samples/images/asciidoctor-js-chrome-extension.png +0 -0
  55. data/data/samples/images/avatars/graphitefriction.png +0 -0
  56. data/data/samples/images/avatars/mogztter.png +0 -0
  57. data/data/samples/images/avatars/mojavelinux.png +0 -0
  58. data/data/samples/images/correct-text-justification.png +0 -0
  59. data/data/samples/images/incorrect-text-justification.png +0 -0
  60. data/data/samples/images/screenshots/chapter-title-day.png +0 -0
  61. data/data/samples/images/screenshots/chapter-title.png +0 -0
  62. data/data/samples/images/screenshots/figure-admonition.png +0 -0
  63. data/data/samples/images/screenshots/section-title-paragraph.png +0 -0
  64. data/data/samples/images/screenshots/sidebar.png +0 -0
  65. data/data/samples/images/screenshots/table.png +0 -0
  66. data/data/samples/images/screenshots/text.png +0 -0
  67. data/data/samples/sample-book.adoc +20 -0
  68. data/data/samples/sample-content.adoc +168 -0
  69. data/data/styles/color-palette.css +28 -0
  70. data/data/styles/epub3-css3-only.css +161 -0
  71. data/data/styles/epub3-fonts.css +94 -0
  72. data/data/styles/epub3.css +1293 -0
  73. data/lib/asciidoctor-epub3.rb +5 -0
  74. data/lib/asciidoctor-epub3/converter.rb +859 -0
  75. data/lib/asciidoctor-epub3/core_ext/string.rb +7 -0
  76. data/lib/asciidoctor-epub3/font_icon_map.rb +376 -0
  77. data/lib/asciidoctor-epub3/packager.rb +466 -0
  78. data/lib/asciidoctor-epub3/spine_item_processor.rb +72 -0
  79. data/lib/asciidoctor-epub3/version.rb +5 -0
  80. data/scripts/generate-font-subsets.pe +225 -0
  81. metadata +192 -0
@@ -0,0 +1,5 @@
1
+ require 'asciidoctor'
2
+ require 'asciidoctor/extensions'
3
+ require 'gepub'
4
+ require_relative 'asciidoctor-epub3/converter'
5
+ require_relative 'asciidoctor-epub3/packager'
@@ -0,0 +1,859 @@
1
+ # encoding: UTF-8
2
+ require_relative 'spine_item_processor'
3
+ require_relative 'font_icon_map'
4
+
5
+ module Asciidoctor
6
+ module Epub3
7
+ #WordJoiner = [8288].pack 'U*'
8
+ WordJoiner = [65279].pack 'U*'
9
+
10
+ # Public: The main converter for the epub3 backend that handles packaging the
11
+ # EPUB3 or KF8 publication file.
12
+ class Converter
13
+ include ::Asciidoctor::Converter
14
+ include ::Asciidoctor::Writer
15
+
16
+ register_for 'epub3'
17
+
18
+ def initialize backend, opts
19
+ super
20
+ basebackend 'html'
21
+ outfilesuffix '.epub' # dummy outfilesuffix since it may be .mobi
22
+ htmlsyntax 'xml'
23
+ @validate = false
24
+ @extract = false
25
+ end
26
+
27
+ def convert spine_doc, name = nil
28
+ @validate = true if spine_doc.attr? 'ebook-validate'
29
+ @extract = true if spine_doc.attr? 'ebook-extract'
30
+ Packager.new spine_doc, (spine_doc.references[:spine_items] || [spine_doc]), spine_doc.attributes['ebook-format'].to_sym
31
+ end
32
+
33
+ # FIXME we have to package in write because we don't have access to target before this point
34
+ def write packager, target
35
+ # NOTE we use dirname of target since filename is calculated automatically
36
+ packager.package validate: @validate, extract: @extract, to_dir: (::File.dirname target)
37
+ nil
38
+ end
39
+ end
40
+
41
+ # Public: The converter for the epub3 backend that converts the individual
42
+ # content documents in an EPUB3 publication.
43
+ class ContentConverter
44
+ include ::Asciidoctor::Converter
45
+
46
+ register_for 'epub3-xhtml5'
47
+
48
+ WordJoiner = Epub3::WordJoiner
49
+ EOL = "\n"
50
+ NoBreakSpace = ' '
51
+ ThinNoBreakSpace = ' '
52
+ RightAngleQuote = '›'
53
+
54
+ XmlElementRx = /<\/?.+?>/
55
+ CharEntityRx = /&#(\d{2,5});/
56
+ NamedEntityRx = /&([A-Z]+);/
57
+ UppercaseTagRx = /<(\/)?([A-Z]+)>/
58
+
59
+ FromHtmlSpecialCharsMap = {
60
+ '&lt;' => '<',
61
+ '&gt;' => '>',
62
+ '&amp;' => '&'
63
+ }
64
+
65
+ FromHtmlSpecialCharsRx = /(?:#{FromHtmlSpecialCharsMap.keys * '|'})/
66
+
67
+ ToHtmlSpecialCharsMap = {
68
+ '&' => '&amp;',
69
+ '<' => '&lt;',
70
+ '>' => '&gt;'
71
+ }
72
+
73
+ ToHtmlSpecialCharsRx = /[#{ToHtmlSpecialCharsMap.keys.join}]/
74
+
75
+ OpenParagraphTagRx = /^<p>/
76
+ CloseParagraphTagRx = /<\/p>$/
77
+
78
+ def initialize backend, opts
79
+ super
80
+ basebackend 'html'
81
+ outfilesuffix '.xhtml'
82
+ htmlsyntax 'xml'
83
+ @xrefs_used = ::Set.new
84
+ @icon_names = []
85
+ end
86
+
87
+ def convert node, name = nil
88
+ if respond_to?(name ||= node.node_name)
89
+ send name, node
90
+ else
91
+ warn %(conversion missing in epub3 backend for #{name})
92
+ end
93
+ end
94
+
95
+ # TODO aggregate authors of spine document into authors attribute(s) on main document
96
+ def navigation_document node, spine
97
+ doctitle_sanitized = ((node.doctitle sanitize: true) || (node.attr 'untitled-label')).gsub WordJoiner, ''
98
+ lines = [%(<!DOCTYPE html>
99
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="#{lang = (node.attr 'lang', 'en')}" lang="#{lang}">
100
+ <head>
101
+ <meta charset="UTF-8"/>
102
+ <title>#{doctitle_sanitized}</title>
103
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
104
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
105
+ </head>
106
+ <body>
107
+ <h1>#{doctitle_sanitized}</h1>
108
+ <nav epub:type="toc" id="toc">
109
+ <h2>#{node.attr 'toc-title'}</h2>
110
+ <ol>)]
111
+ spine.each do |item|
112
+ lines << %(<li><a href="#{item.id || (item.attr 'docname')}.xhtml">#{((item.doctitle sanitize: true) || (item.attr 'untitled-label')).gsub WordJoiner, ''}</a></li>)
113
+ end
114
+ lines << %(</ol>
115
+ </nav>
116
+ </body>
117
+ </html>)
118
+ lines * EOL
119
+ end
120
+
121
+ def document node
122
+ docid = node.id
123
+ if (doctitle = node.doctitle)
124
+ doctitle_sanitized = (node.doctitle sanitize: :sgml).gsub WordJoiner, ''
125
+ if doctitle.include? ': '
126
+ title, _, subtitle = doctitle.rpartition ': '
127
+ else
128
+ # HACK until we get proper handling of title-only in CSS
129
+ title = ''
130
+ subtitle = doctitle
131
+ end
132
+ else
133
+ # HACK until we get proper handling of title-only in CSS
134
+ title = ''
135
+ subtitle = node.attr 'untitled-label'
136
+ end
137
+ subtitle_formatted = subtitle.gsub(WordJoiner, '').split(' ').map {|w| %(<b>#{w}</b>) } * ' '
138
+
139
+ title_upper = title.upcase
140
+ # FIXME make this uppercase routine more intelligent, less fragile
141
+ subtitle_formatted_upper = subtitle_formatted.upcase
142
+ .gsub(UppercaseTagRx) { %(<#{$1}#{$2.downcase}>) }
143
+ .gsub(NamedEntityRx) { %(&#{$1.downcase};) }
144
+
145
+ author = node.attr 'author'
146
+ username = node.attr 'username', 'default'
147
+ # FIXME needs to resolve to the imagesdir of the spine document, not this document
148
+ #imagesdir = (node.attr 'imagesdir', '.').chomp '/'
149
+ #imagesdir = (imagesdir == '.' ? nil : %(#{imagesdir}/))
150
+ imagesdir = 'images/'
151
+
152
+ mark_last_paragraph node
153
+ content = node.content
154
+
155
+ # NOTE must run after content is resolved
156
+ # NOTE pubtree requires icon CSS to be repeated inside <body> (or in a linked stylesheet); perhaps create dynamic CSS file?
157
+ icon_css = unless @icon_names.empty?
158
+ icon_defs = @icon_names.map {|name|
159
+ %(.i-#{name}::before { content: "#{FontIconMap[name.tr('-', '_').to_sym]}"; })
160
+ } * EOL
161
+ %(<style>
162
+ #{icon_defs}
163
+ </style>
164
+ )
165
+ end
166
+
167
+ # NOTE kindlegen seems to mangle the <header> element, so we wrap its content in a div
168
+ lines = [%(<!DOCTYPE html>
169
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="#{lang = (node.attr 'lang', 'en')}" lang="#{lang}">
170
+ <head>
171
+ <meta charset="UTF-8"/>
172
+ <title>#{doctitle_sanitized}</title>
173
+ <link rel="stylesheet" type="text/css" href="styles/epub3.css"/>
174
+ <link rel="stylesheet" type="text/css" href="styles/epub3-css3-only.css" media="(min-device-width: 0px)"/>
175
+ #{icon_css}<script type="text/javascript">
176
+ document.addEventListener('DOMContentLoaded', function(event) {
177
+ var epubReader = navigator.epubReadingSystem;
178
+ if (!epubReader) {
179
+ if (window.parent == window || !(epubReader = window.parent.navigator.epubReadingSystem)) {
180
+ return;
181
+ }
182
+ }
183
+ document.body.setAttribute('class', epubReader.name.toLowerCase().replace(/ /g, '-'));
184
+ });
185
+ </script>
186
+ </head>
187
+ <body>
188
+ <section class="chapter" title="#{doctitle_sanitized.gsub '"', '&quot;'}" epub:type="chapter" id="#{docid}">
189
+ #{icon_css && (icon_css.sub '<style>', '<style scoped="scoped">')}<header>
190
+ <div class="chapter-header">
191
+ <p class="byline"><img src="#{imagesdir}avatars/#{username}.jpg"/> <b class="author">#{author}</b></p>
192
+ <h1 class="chapter-title">#{title_upper}#{subtitle ? %[ <small class="subtitle">#{subtitle_formatted_upper}</small>] : nil}</h1>
193
+ </div>
194
+ </header>
195
+ #{content})]
196
+
197
+ if node.footnotes?
198
+ # NOTE kindlegen seems to mangle the <footer> element, so we wrap its content in a div
199
+ lines << '<footer>
200
+ <div class="chapter-footer">
201
+ <div class="footnotes">'
202
+ node.footnotes.each do |footnote|
203
+ lines << %(<aside id="note-#{footnote.index}" epub:type="footnote">
204
+ <p><sup class="noteref"><a href="#noteref-#{footnote.index}">#{footnote.index}</a></sup> #{footnote.text}</p>
205
+ </aside>)
206
+ end
207
+ lines << '</div>
208
+ </div>
209
+ </footer>'
210
+ end
211
+
212
+ lines << '</section>
213
+ </body>
214
+ </html>'
215
+
216
+ lines * EOL
217
+ end
218
+
219
+ def section node
220
+ hlevel = node.level + 1
221
+ epub_type_attr = node.special ? %( epub:type="#{node.sectname}") : nil
222
+ div_classes = [%(sect#{node.level}), node.role].compact
223
+ title = node.title
224
+ title_sanitized = xml_sanitize title
225
+ if node.document.header? || node.level != 1 || node != node.document.first_section
226
+ %(<section class="#{div_classes * ' '}" title="#{title_sanitized}"#{epub_type_attr}>
227
+ <h#{hlevel} id="#{node.id}">#{title}</h#{hlevel}>#{(content = node.content).empty? ? nil : %[
228
+ #{content}]}
229
+ </section>)
230
+ else
231
+ # document has no level-0 heading and this heading serves as the document title
232
+ node.content
233
+ end
234
+ end
235
+
236
+ # TODO support use of quote block as abstract
237
+ def preamble node
238
+ if (first_block = node.blocks[0]) && first_block.style == 'abstract'
239
+ abstract first_block
240
+ # REVIEW should we treat the preamble as an abstract in general?
241
+ elsif first_block && node.blocks.size == 1
242
+ abstract first_block
243
+ else
244
+ node.content
245
+ end
246
+ end
247
+
248
+ # QUESTION use convert_content?
249
+ def open node
250
+ node.content
251
+ end
252
+
253
+ def abstract node
254
+ %(<div class="abstract" epub:type="preamble">
255
+ #{convert_content node}
256
+ </div>)
257
+ end
258
+
259
+ def paragraph node
260
+ role = node.role
261
+ # stack-head is the alternative to the default, inline-head (where inline means "run-in")
262
+ head_stop = node.attr 'head-stop', (role && (node.has_role? 'stack-head') ? nil : '.')
263
+ head = node.title? ? %(<strong class="head">#{title = node.title}#{head_stop && title !~ /[[:punct:]]$/ ? head_stop : nil}</strong> ) : nil
264
+ if role
265
+ if node.has_role? 'signature'
266
+ node.set_option 'hardbreaks'
267
+ end
268
+ %(<p class="#{role}">#{head}#{node.content}</p>)
269
+ else
270
+ %(<p>#{head}#{node.content}</p>)
271
+ end
272
+ end
273
+
274
+ def pass node
275
+ content = node.content
276
+ if content == '<?hard-pagebreak?>'
277
+ '<hr epub:type="pagebreak" class="pagebreak"/>'
278
+ else
279
+ content
280
+ end
281
+ end
282
+
283
+ def admonition node
284
+ if node.title?
285
+ title = node.title
286
+ title_sanitized = xml_sanitize title
287
+ title_attr = %( title="#{node.caption}: #{title_sanitized}")
288
+ title_el = %(<h2>#{title}</h2>
289
+ )
290
+ else
291
+ title_attr = %( title="#{node.caption}")
292
+ title_el = nil
293
+ end
294
+
295
+ type = node.attr 'name'
296
+ epub_type = case type
297
+ when 'tip'
298
+ 'help'
299
+ when 'note'
300
+ 'note'
301
+ when 'important', 'warning', 'caution'
302
+ 'warning'
303
+ end
304
+ %(<aside class="admonition #{type}"#{title_attr} epub:type="#{epub_type}">
305
+ #{title_el}<div class="content">
306
+ #{convert_content node}
307
+ </div>
308
+ </aside>)
309
+ end
310
+
311
+ def example node
312
+ title_div = node.title? ? %(<div class="example-title">#{node.title}</div>
313
+ ) : nil
314
+ %(<div class="example">
315
+ #{title_div}<div class="example-content">
316
+ #{convert_content node}
317
+ </div>
318
+ </div>)
319
+ end
320
+
321
+ def listing node
322
+ figure_classes = ['listing']
323
+ figure_classes << 'coalesce' if node.option? 'unbreakable'
324
+ pre_classes = if node.style == 'source'
325
+ ['source', %(language-#{node.attr 'language'})]
326
+ else
327
+ ['screen']
328
+ end
329
+ title_div = node.title? ? %(<figcaption>#{node.captioned_title}</figcaption>
330
+ ) : nil
331
+ # patches conums to fix extra or missing leading space
332
+ # TODO apply this patch upstream to Asciidoctor
333
+ %(<figure class="#{figure_classes * ' '}">
334
+ #{title_div}<pre class="#{pre_classes * ' '}"><code>#{node.content.gsub(/(?<! )<i class="conum"| +<i class="conum"/, ' <i class="conum"')}</code></pre>
335
+ </figure>)
336
+ end
337
+
338
+ # QUESTION should we wrap the <pre> in either <div> or <figure>?
339
+ def literal node
340
+ %(<pre class="screen">#{node.content}</pre>)
341
+ end
342
+
343
+ def page_break node
344
+ '<hr epub:type="pagebreak" class="pagebreak"/>'
345
+ end
346
+
347
+ def thematic_break node
348
+ '<hr class="thematicbreak"/>'
349
+ end
350
+
351
+ def quote node
352
+ footer_content = []
353
+ if attribution = (node.attr 'attribution')
354
+ footer_content << attribution
355
+ end
356
+
357
+ if citetitle = (node.attr 'citetitle')
358
+ citetitle_sanitized = xml_sanitize citetitle
359
+ footer_content << %(<cite title="#{citetitle_sanitized}">#{citetitle}</cite>)
360
+ end
361
+
362
+ if node.title?
363
+ footer_content << %(<span class="context">#{node.title}</span>)
364
+ end
365
+
366
+ footer_tag = footer_content.empty? ? nil : %(
367
+ <footer>~ #{footer_content * ' '}</footer>)
368
+ content = (convert_content node).strip.
369
+ sub(OpenParagraphTagRx, '<p><span class="open-quote">“</span>').
370
+ sub(CloseParagraphTagRx, '<span class="close-quote">”</span></p>')
371
+ %(<div class="blockquote">
372
+ <blockquote>
373
+ #{content}#{footer_tag}
374
+ </blockquote>
375
+ </div>)
376
+ end
377
+
378
+ def verse node
379
+ footer_content = []
380
+ if attribution = (node.attr 'attribution')
381
+ footer_content << attribution
382
+ end
383
+
384
+ if citetitle = (node.attr 'citetitle')
385
+ citetitle_sanitized = xml_sanitize citetitle
386
+ footer_content << %(<cite title="#{citetitle_sanitized}">#{citetitle}</cite>)
387
+ end
388
+
389
+ footer_tag = footer_content.size > 0 ? %(
390
+ <span class="attribution">~ #{footer_content * ', '}</span>) : nil
391
+ %(<div class="verse">
392
+ <pre>#{node.content}#{footer_tag}</pre>
393
+ </div>)
394
+ end
395
+
396
+ def sidebar node
397
+ classes = ['sidebar']
398
+ if node.title?
399
+ classes << 'titled'
400
+ title = node.title
401
+ title_sanitized = xml_sanitize title
402
+ title_attr = %( title="#{title_sanitized}")
403
+ title_upper = title.upcase.gsub(NamedEntityRx) { %(&#{$1.downcase};) }
404
+ title_el = %(<h2>#{title_upper}</h2>
405
+ )
406
+ else
407
+ title_attr = nil
408
+ title_el = nil
409
+ end
410
+
411
+ %(<aside class="#{classes * ' '}"#{title_attr} epub:type="sidebar">
412
+ #{title_el}<div class="content">
413
+ #{convert_content node}
414
+ </div>
415
+ </aside>)
416
+ end
417
+
418
+ def table node
419
+ lines = [%(<div class="table">)]
420
+ lines << %(<div class="content">)
421
+ table_id_attr = node.id ? %( id="#{node.id}") : nil
422
+ frame_class = {
423
+ 'all' => 'table-framed',
424
+ 'topbot' => 'table-framed-topbot',
425
+ 'sides' => 'table-framed-sides'
426
+ }
427
+ grid_class = {
428
+ 'all' => 'table-grid',
429
+ 'rows' => 'table-grid-rows',
430
+ 'cols' => 'table-grid-cols'
431
+ }
432
+ table_classes = %W(table #{frame_class[(node.attr 'frame')] || frame_class['topbot']} #{grid_class[(node.attr 'grid')] || grid_class['rows']})
433
+ if (role = node.role)
434
+ table_classes << role
435
+ end
436
+ table_class_attr = %( class="#{table_classes * ' '}")
437
+ table_styles = []
438
+ unless node.option? 'autowidth'
439
+ table_styles << %(width: #{node.attr 'tablepcwidth'}%;)
440
+ end
441
+ table_style_attr = table_styles.size > 0 ? %( style="#{table_styles * ' '}") : nil
442
+
443
+ lines << %(<table#{table_id_attr}#{table_class_attr}#{table_style_attr}>)
444
+ lines << %(<caption>#{node.captioned_title}</caption>) if node.title?
445
+ if (node.attr 'rowcount') > 0
446
+ lines << '<colgroup>'
447
+ #if node.option? 'autowidth'
448
+ tag = %(<col/>)
449
+ node.columns.size.times do
450
+ lines << tag
451
+ end
452
+ #else
453
+ # node.columns.each do |col|
454
+ # lines << %(<col style="width: #{col.attr 'colpcwidth'}%;"/>)
455
+ # end
456
+ #end
457
+ lines << '</colgroup>'
458
+ [:head, :foot, :body].select {|tsec| !node.rows[tsec].empty? }.each do |tsec|
459
+ lines << %(<t#{tsec}>)
460
+ node.rows[tsec].each do |row|
461
+ lines << '<tr>'
462
+ row.each do |cell|
463
+ if tsec == :head
464
+ cell_content = cell.text
465
+ else
466
+ case cell.style
467
+ when :asciidoc
468
+ cell_content = %(<div>#{cell.content}</div>)
469
+ when :verse
470
+ cell_content = %(<div class="verse">#{cell.text}</div>)
471
+ when :literal
472
+ cell_content = %(<div class="literal"><pre>#{cell.text}</pre></div>)
473
+ else
474
+ cell_content = ''
475
+ cell.content.each do |text|
476
+ cell_content = %(#{cell_content}<p>#{text}</p>)
477
+ end
478
+ end
479
+ end
480
+
481
+ cell_tag_name = (tsec == :head || cell.style == :header ? 'th' : 'td')
482
+ cell_classes = []
483
+ if (halign = cell.attr 'halign') && halign != 'left'
484
+ cell_classes << 'halign-left'
485
+ end
486
+ if (halign = cell.attr 'valign') && halign != 'top'
487
+ cell_classes << 'valign-top'
488
+ end
489
+ cell_class_attr = cell_classes.size > 0 ? %( class="#{cell_classes * ' '}") : nil
490
+ cell_colspan_attr = cell.colspan ? %( colspan="#{cell.colspan}") : nil
491
+ cell_rowspan_attr = cell.rowspan ? %( rowspan="#{cell.rowspan}") : nil
492
+ cell_style_attr = (node.document.attr? 'cellbgcolor') ? %( style="background-color: #{node.document.attr 'cellbgcolor'};") : nil
493
+ lines << %(<#{cell_tag_name}#{cell_class_attr}#{cell_colspan_attr}#{cell_rowspan_attr}#{cell_style_attr}>#{cell_content}</#{cell_tag_name}>)
494
+ end
495
+ lines << '</tr>'
496
+ end
497
+ lines << %(</t#{tsec}>)
498
+ end
499
+ end
500
+ lines << '</table>
501
+ </div>
502
+ </div>'
503
+ lines * EOL
504
+ end
505
+
506
+ def colist node
507
+ lines = ['<div class="callout-list">
508
+ <ol>']
509
+ num = "\u2460"
510
+ node.items.each_with_index do |item, i|
511
+ lines << %(<li><i class="conum" data-value="#{i + 1}">#{num}</i> #{item.text}</li>)
512
+ num = num.next
513
+ end
514
+ lines << '</ol>
515
+ </div>'
516
+ end
517
+
518
+ # TODO add complex class if list has nested blocks
519
+ def dlist node
520
+ lines = []
521
+ case (style = node.style)
522
+ when 'itemized', 'ordered'
523
+ list_tag_name = (style == 'itemized' ? 'ul' : 'ol')
524
+ role = node.role
525
+ subject_stop = node.attr 'subject-stop', (role && (node.has_role? 'stack') ? nil : ':')
526
+ # QUESTION should we just use itemized-list and ordered-list as the class here? or just list?
527
+ div_classes = [%(#{style}-list), role].compact
528
+ list_class_attr = (node.option? 'brief') ? ' class="brief"' : nil
529
+ lines << %(<div class="#{div_classes * ' '}">
530
+ <#{list_tag_name}#{list_class_attr}#{list_tag_name == 'ol' && (node.option? 'reversed') ? ' reversed="reversed"' : nil}>)
531
+ node.items.each do |subjects, dd|
532
+ # consists of one term (a subject) and supporting content
533
+ subject = [*subjects].first.text
534
+ subject_plain = xml_sanitize subject, :plain
535
+ subject_element = %(<strong class="subject">#{subject}#{subject_stop && subject_plain !~ /[[:punct:]]$/ ? subject_stop : nil}</strong>)
536
+ lines << '<li>'
537
+ if dd
538
+ # NOTE: must wrap remaining text in a span to help webkit justify the text properly
539
+ lines << %(<span class="principal">#{subject_element}#{dd.text? ? %[ <span class="supporting">#{dd.text}</span>] : nil}</span>)
540
+ lines << dd.content if dd.blocks?
541
+ else
542
+ lines << %(<span class="principal">#{subject_element}</span>)
543
+ end
544
+ lines << '</li>'
545
+ end
546
+ lines << %(</#{list_tag_name}>
547
+ </div>)
548
+ else
549
+ lines << '<div class="description-list">
550
+ <dl>'
551
+ node.items.each do |terms, dd|
552
+ [*terms].each do |dt|
553
+ lines << %(<dt>
554
+ <span class="term">#{dt.text}</span>
555
+ </dt>)
556
+ end
557
+ if dd
558
+ lines << '<dd>'
559
+ if dd.blocks?
560
+ lines << %(<span class="principal">#{dd.text}</span>) if dd.text?
561
+ lines << dd.content
562
+ else
563
+ lines << dd.text
564
+ end
565
+ lines << '</dd>'
566
+ end
567
+ end
568
+ lines << '</dl>
569
+ </div>'
570
+ end
571
+ lines * EOL
572
+ end
573
+
574
+ # TODO support start attribute
575
+ def olist node
576
+ complex = false
577
+ div_classes = ['ordered-list', node.style, node.role].compact
578
+ ol_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
579
+ ol_class_attr = ol_classes.empty? ? nil : %( class="#{ol_classes * ' '}")
580
+ id_attribute = node.id ? %( id="#{node.id}") : nil
581
+ lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
582
+ lines << %(<h3>#{node.title}</h3>) if node.title?
583
+ lines << %(<ol#{ol_class_attr}#{(node.option? 'reversed') ? ' reversed="reversed"' : nil}>)
584
+ node.items.each do |item|
585
+ lines << %(<li>
586
+ <span class="principal">#{item.text}</span>)
587
+ if item.blocks?
588
+ lines << item.content
589
+ complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
590
+ end
591
+ lines << '</li>'
592
+ end
593
+ if complex
594
+ div_classes << 'complex'
595
+ lines[0] = %(<div class="#{div_classes * ' '}">)
596
+ end
597
+ lines << '</ol>
598
+ </div>'
599
+ lines * EOL
600
+ end
601
+
602
+ def ulist node
603
+ complex = false
604
+ div_classes = ['itemized-list', node.style, node.role].compact
605
+ # TODO could strip WordJoiner if brief since not using justify
606
+ ul_classes = [node.style, ((node.option? 'brief') ? 'brief' : nil)].compact
607
+ ul_class_attr = ul_classes.empty? ? nil : %( class="#{ul_classes * ' '}")
608
+ id_attribute = node.id ? %( id="#{node.id}") : nil
609
+ lines = [%(<div#{id_attribute} class="#{div_classes * ' '}">)]
610
+ lines << %(<h3>#{node.title}</h3>) if node.title?
611
+ lines << %(<ul#{ul_class_attr}>)
612
+ node.items.each do |item|
613
+ lines << %(<li>
614
+ <span class="principal">#{item.text}</span>)
615
+ if item.blocks?
616
+ lines << item.content
617
+ complex = true unless item.blocks.size == 1 && ::Asciidoctor::List === item.blocks[0]
618
+ end
619
+ lines << '</li>'
620
+ end
621
+ if complex
622
+ div_classes << 'complex'
623
+ lines[0] = %(<div class="#{div_classes * ' '}">)
624
+ end
625
+ lines << '</ul>
626
+ </div>'
627
+ lines * EOL
628
+ end
629
+
630
+ def image node
631
+ target = node.attr 'target'
632
+ type = (::File.extname target)[1..-1]
633
+ img_attrs = [%(alt="#{node.attr 'alt'}")]
634
+ case type
635
+ when 'svg'
636
+ img_attrs << %(style="width: #{node.attr 'scaledwidth', '100%'};")
637
+ # TODO make this a convenience method on document
638
+ epub_properties = (node.document.attr 'epub-properties') || []
639
+ unless epub_properties.include? 'svg'
640
+ epub_properties << 'svg'
641
+ node.document.attributes['epub-properties'] = epub_properties
642
+ end
643
+ else
644
+ if node.attr? 'scaledwidth'
645
+ img_attrs << %(style="width: #{node.attr 'scaledwidth'};")
646
+ end
647
+ end
648
+ =begin
649
+ # NOTE to set actual width and height, use CSS width and height
650
+ if type == 'svg'
651
+ if node.attr? 'scaledwidth'
652
+ img_attrs << %(width="#{node.attr 'scaledwidth'}")
653
+ # Kindle
654
+ #elsif node.attr? 'scaledheight'
655
+ # img_attrs << %(width="#{node.attr 'scaledheight'}" height="#{node.attr 'scaledheight'}")
656
+ # ePub3
657
+ elsif node.attr? 'scaledheight'
658
+ img_attrs << %(height="#{node.attr 'scaledheight'}" style="max-height: #{node.attr 'scaledheight'} !important;")
659
+ else
660
+ # Aldiko doesn't not scale width to 100% by default
661
+ img_attrs << %(width="100%")
662
+ end
663
+ end
664
+ =end
665
+ %(<figure class="image">
666
+ <div class="content">
667
+ <img src="#{node.image_uri node.attr('target')}" #{img_attrs * ' '}/>
668
+ </div>#{node.title? ? %[
669
+ <figcaption>#{node.captioned_title}</figcaption>] : nil}
670
+ </figure>)
671
+ end
672
+
673
+ def inline_anchor node
674
+ target = node.target
675
+ case node.type
676
+ when :xref
677
+ refid = (node.attr 'refid') || target
678
+ id_attr = unless @xrefs_used.include? refid
679
+ @xrefs_used << refid
680
+ %( id="xref-#{refid}")
681
+ end
682
+ # FIXME seems like text should be prepared already
683
+ # FIXME would be nice to know what type the target is (e.g., bibref)
684
+ text = node.text || (node.document.references[:ids][refid] || %([#{refid}]))
685
+ %(<a#{id_attr} href="#{target}" class="xref">#{text}</a>#{WordJoiner})
686
+ when :ref
687
+ %(<a id="#{target}"></a>)
688
+ when :link
689
+ %(<a href="#{target}" class="link">#{node.text}</a>#{WordJoiner})
690
+ when :bibref
691
+ %(<a id="#{target}" href="#xref-#{target}">[#{target}]</a>#{WordJoiner})
692
+ end
693
+ end
694
+
695
+ def inline_break node
696
+ %(#{node.text}<br/>)
697
+ end
698
+
699
+ def inline_button node
700
+ %(<b class="button">[<span class="label">#{node.text}</span>]</b>#{WordJoiner})
701
+ end
702
+
703
+ def inline_callout node
704
+ num = "\u2460"
705
+ int_num = node.text.to_i
706
+ (int_num - 1).times { num = num.next }
707
+ %(<i class="conum" data-value="#{int_num}">#{num}</i>)
708
+ end
709
+
710
+ def inline_footnote node
711
+ if (index = node.attr 'index')
712
+ %(<sup class="noteref">[<a id="noteref-#{index}" href="#note-#{index}" epub:type="noteref">#{index}</a>]</sup>)
713
+ elsif node.type == :xref
714
+ %(<mark class="noteref" title="Unresolved note reference">#{node.text}</mark>)
715
+ end
716
+ end
717
+
718
+ def inline_image node
719
+ if (type = node.type) == 'icon'
720
+ @icon_names << (icon_name = node.target)
721
+ i_classes = ['icon', %(i-#{icon_name})]
722
+ i_classes << %(icon-#{node.attr 'size'}) if node.attr? 'size'
723
+ i_classes << %(icon-flip-#{(node.attr 'flip')[0]}) if node.attr? 'flip'
724
+ i_classes << %(icon-rotate-#{node.attr 'rotate'}) if node.attr? 'rotate'
725
+ i_classes << node.role if node.role?
726
+ %(<i class="#{i_classes * ' '}"></i>)
727
+ else
728
+ target = node.image_uri node.target
729
+ class_attr = %( class="#{node.role}") if node.role?
730
+ %(<img src="#{target}" alt="#{node.attr 'alt'}"#{class_attr}/>)
731
+ end
732
+ end
733
+
734
+ def inline_indexterm node
735
+ node.type == :visible ? node.text : ''
736
+ end
737
+
738
+ def inline_kbd node
739
+ if (keys = node.attr 'keys').size == 1
740
+ %(<kbd>#{keys[0]}</kbd>)
741
+ else
742
+ key_combo = keys.map {|key| %(<kbd>#{key}</kbd>+) }.join.chop
743
+ %(<span class="keyseq">#{key_combo}</span>)
744
+ end
745
+ end
746
+
747
+ def inline_menu node
748
+ menu = node.attr 'menu'
749
+ # NOTE we swap right angle quote with chevron right from FontAwesome using CSS
750
+ caret = %(#{NoBreakSpace}<span class="caret">#{RightAngleQuote}</span> )
751
+ if !(submenus = node.attr 'submenus').empty?
752
+ submenu_path = submenus.map {|submenu| %(<span class="submenu">#{submenu}</span>#{caret}) }.join.chop
753
+ %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}#{submenu_path} <span class="menuitem">#{node.attr 'menuitem'}</span></span>)
754
+ elsif (menuitem = node.attr 'menuitem')
755
+ %(<span class="menuseq"><span class="menu">#{menu}</span>#{caret}<span class="menuitem">#{menuitem}</span></span>)
756
+ else
757
+ %(<span class="menu">#{menu}</span>)
758
+ end
759
+ end
760
+
761
+ def inline_quoted node
762
+ case node.type
763
+ when :strong
764
+ %(<strong>#{node.text}</strong>#{WordJoiner})
765
+ when :emphasis
766
+ %(<em>#{node.text}</em>#{WordJoiner})
767
+ when :monospaced
768
+ %(<code class="literal">#{node.text}</code>#{WordJoiner})
769
+ when :double
770
+ #%(&#x201c;#{node.text}&#x201d;)
771
+ %(“#{node.text}”)
772
+ when :single
773
+ #%(&#x2018;#{node.text}&#x2019;)
774
+ %(‘#{node.text}’)
775
+ when :superscript
776
+ %(<sup>#{node.text}</sup>#{WordJoiner})
777
+ when :subscript
778
+ %(<sub>#{node.text}</sub>#{WordJoiner})
779
+ else
780
+ node.text
781
+ end
782
+ end
783
+
784
+ def convert_content node
785
+ if node.content_model == :simple
786
+ %(<p>#{node.content}</p>)
787
+ else
788
+ node.content
789
+ end
790
+ end
791
+
792
+ def xml_sanitize value, target = :attribute
793
+ sanitized = (value.include? '<') ? value.gsub(XmlElementRx, '').tr_s(' ', ' ').strip : value
794
+ if target == :plain && (sanitized.include? ';')
795
+ sanitized = sanitized.gsub(CharEntityRx) { [$1.to_i].pack('U*') }.gsub(FromHtmlSpecialCharsRx, FromHtmlSpecialCharsMap)
796
+ elsif target == :attribute
797
+ sanitized = sanitized.gsub(WordJoiner, '').gsub('"', '&quot;')
798
+ end
799
+ sanitized
800
+ end
801
+
802
+ # TODO make check for last content paragraph a feature of Asciidoctor
803
+ def mark_last_paragraph root
804
+ return unless (last_block = root.blocks[-1])
805
+ while last_block.context == :section && last_block.blocks?
806
+ last_block = last_block.blocks[-1]
807
+ end
808
+ if last_block.context == :paragraph
809
+ last_block.attributes['role'] = last_block.role? ? %(#{last_block.role} last) : 'last'
810
+ end
811
+ nil
812
+ end
813
+ end
814
+
815
+ class DocumentIdGenerator
816
+ class << self
817
+ def generate_id doc
818
+ unless (id = doc.id)
819
+ id = if doc.header?
820
+ doc.doctitle(sanitize: :sgml).gsub(WordJoiner, '').downcase.delete(':').tr_s(' ', '-').tr_s('-', '-')
821
+ elsif (first_section = doc.first_section)
822
+ first_section.id
823
+ else
824
+ %(document-#{doc.object_id})
825
+ end
826
+ end
827
+ id
828
+ end
829
+ end
830
+ end
831
+
832
+ require_relative 'packager'
833
+
834
+ Extensions.register do
835
+ if (document = @document).backend == 'epub3'
836
+ document.attributes['spine'] = ''
837
+ document.set_attribute 'listing-caption', 'Listing'
838
+ if !(defined? ::AsciidoctorJ) && (::Gem::try_activate 'pygments.rb')
839
+ if document.set_attribute 'source-highlighter', 'pygments'
840
+ document.set_attribute 'pygments-css', 'style'
841
+ document.set_attribute 'pygments-style', 'bw'
842
+ end
843
+ end
844
+ case (ebook_format = document.attributes['ebook-format'])
845
+ when 'epub3', 'kf8'
846
+ # all good
847
+ when 'mobi'
848
+ document.attributes['ebook-format'] = 'kf8'
849
+ else
850
+ document.attributes['ebook-format'] = 'epub3'
851
+ end
852
+ document.attributes[%(ebook-format-#{ebook_format})] = ''
853
+ # Only fire SpineItemProcessor for top-level include directives
854
+ include_processor SpineItemProcessor.new(document)
855
+ treeprocessor { process {|doc| doc.id = DocumentIdGenerator.generate_id doc } }
856
+ end
857
+ end
858
+ end
859
+ end