asciidoctor-pdf 1.5.0.alpha.13 → 1.5.0.alpha.14

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +131 -4
  3. data/Gemfile +8 -1
  4. data/README.adoc +199 -36
  5. data/Rakefile +1 -0
  6. data/asciidoctor-pdf.gemspec +4 -4
  7. data/data/themes/base-theme.yml +9 -2
  8. data/data/themes/default-theme.yml +34 -20
  9. data/docs/theming-guide.adoc +1147 -268
  10. data/lib/asciidoctor-pdf/asciidoctor_ext/image.rb +7 -1
  11. data/lib/asciidoctor-pdf/converter.rb +841 -409
  12. data/lib/asciidoctor-pdf/core_ext.rb +1 -0
  13. data/lib/asciidoctor-pdf/core_ext/numeric.rb +12 -8
  14. data/lib/asciidoctor-pdf/core_ext/object.rb +6 -0
  15. data/lib/asciidoctor-pdf/core_ext/string.rb +15 -0
  16. data/lib/asciidoctor-pdf/formatted_text/inline_destination_marker.rb +5 -0
  17. data/lib/asciidoctor-pdf/formatted_text/inline_image_arranger.rb +75 -28
  18. data/lib/asciidoctor-pdf/formatted_text/inline_image_renderer.rb +3 -2
  19. data/lib/asciidoctor-pdf/formatted_text/transform.rb +97 -89
  20. data/lib/asciidoctor-pdf/index_catalog.rb +119 -0
  21. data/lib/asciidoctor-pdf/measurements.rb +58 -0
  22. data/lib/asciidoctor-pdf/pdf-core_ext.rb +1 -0
  23. data/lib/asciidoctor-pdf/{pdf_core_ext → pdf-core_ext}/page.rb +3 -7
  24. data/lib/asciidoctor-pdf/pdfmark.rb +33 -0
  25. data/lib/asciidoctor-pdf/prawn-svg_ext.rb +4 -0
  26. data/lib/asciidoctor-pdf/prawn-svg_ext/interface.rb +10 -0
  27. data/lib/asciidoctor-pdf/prawn-table_ext.rb +3 -0
  28. data/lib/asciidoctor-pdf/prawn-table_ext/cell/asciidoc.rb +69 -0
  29. data/lib/asciidoctor-pdf/prawn-table_ext/cell/text.rb +12 -0
  30. data/lib/asciidoctor-pdf/prawn_ext/extensions.rb +89 -76
  31. data/lib/asciidoctor-pdf/prawn_ext/images.rb +0 -1
  32. data/lib/asciidoctor-pdf/roman_numeral.rb +1 -1
  33. data/lib/asciidoctor-pdf/rouge_ext/formatters/prawn.rb +51 -11
  34. data/lib/asciidoctor-pdf/rouge_ext/themes/pastie.rb +64 -59
  35. data/lib/asciidoctor-pdf/sanitizer.rb +53 -2
  36. data/lib/asciidoctor-pdf/theme_loader.rb +8 -21
  37. data/lib/asciidoctor-pdf/version.rb +1 -1
  38. metadata +19 -13
  39. data/docs/theme-schema.json +0 -114
  40. data/lib/asciidoctor-pdf/pdf_core_ext.rb +0 -2
  41. data/lib/asciidoctor-pdf/pdf_core_ext/pdf_object.rb +0 -25
  42. data/lib/asciidoctor-pdf/pdfmarks.rb +0 -35
@@ -1,5 +1,7 @@
1
1
  module Asciidoctor
2
2
  module Image
3
+ DataUriRx = /^data:image\/(?<fmt>png|jpe?g|gif|pdf|bmp|tiff);base64,(?<data>.*)$/
4
+
3
5
  class << self
4
6
  def format path, node = nil
5
7
  (node && (node.attr 'format', nil, false)) || (::File.extname path).downcase[1..-1]
@@ -12,7 +14,11 @@ module Image
12
14
 
13
15
  def target_and_format
14
16
  image_path = inline? ? target : (attr 'target')
15
- [image_path, (attr 'format', nil, false) || (::File.extname image_path).downcase[1..-1]]
17
+ if (image_path.start_with? 'data:') && (m = DataUriRx.match image_path)
18
+ [(m[:data].extend ::Base64), m[:fmt]]
19
+ else
20
+ [image_path, (attr 'format', nil, false) || (::File.extname image_path).downcase[1..-1]]
21
+ end
16
22
  end
17
23
  end
18
24
  end
@@ -1,20 +1,27 @@
1
1
  # encoding: UTF-8
2
2
  # TODO cleanup imports...decide what belongs in asciidoctor-pdf.rb
3
3
  require 'prawn'
4
- require 'prawn-svg'
5
- require 'prawn/table'
4
+ begin
5
+ require 'prawn/gmagick'
6
+ rescue LoadError
7
+ end unless defined? GMagick::Image
8
+ require_relative 'prawn-svg_ext'
9
+ require_relative 'prawn-table_ext'
6
10
  require 'prawn/templates'
7
11
  require_relative 'core_ext'
8
- require_relative 'pdf_core_ext'
12
+ require_relative 'pdf-core_ext'
9
13
  require_relative 'temporary_path'
14
+ require_relative 'measurements'
10
15
  require_relative 'sanitizer'
11
16
  require_relative 'prawn_ext'
12
17
  require_relative 'formatted_text'
13
- require_relative 'pdfmarks'
18
+ require_relative 'pdfmark'
14
19
  require_relative 'asciidoctor_ext'
15
20
  require_relative 'theme_loader'
16
21
  require_relative 'roman_numeral'
22
+ require_relative 'index_catalog'
17
23
 
24
+ autoload :StringIO, 'stringio'
18
25
  autoload :Tempfile, 'tempfile'
19
26
 
20
27
  module Asciidoctor
@@ -38,7 +45,8 @@ class Converter < ::Prawn::Document
38
45
  tip: { name: 'fa-lightbulb-o', stroke_color: '111111', size: 24 },
39
46
  warning: { name: 'fa-exclamation-triangle', stroke_color: 'BF6900', size: 24 }
40
47
  }
41
- AlignmentNames = ['left', 'center', 'right']
48
+ TextAlignmentNames = ['left', 'center', 'right', 'justify']
49
+ BlockAlignmentNames = ['left', 'center', 'right']
42
50
  AlignmentTable = { '<' => :left, '=' => :center, '>' => :right }
43
51
  ColumnPositions = [:left, :center, :right]
44
52
  PageSides = [:recto, :verso]
@@ -57,8 +65,10 @@ class Converter < ::Prawn::Document
57
65
  NarrowNoBreakSpace = %(\u202f)
58
66
  ZeroWidthSpace = %(\u200b)
59
67
  HairSpace = %(\u200a)
60
- DotLeaderDefault = '. '
68
+ DummyText = %(\u0000)
69
+ DotLeaderTextDefault = '. '
61
70
  EmDash = %(\u2014)
71
+ RightPointer = %(\u25ba)
62
72
  LowercaseGreekA = %(\u03b1)
63
73
  Bullets = {
64
74
  disc: %(\u2022),
@@ -71,16 +81,20 @@ class Converter < ::Prawn::Document
71
81
  unchecked: %(\u2610)
72
82
  }
73
83
  SimpleAttributeRefRx = /(?<!\\)\{\w+(?:[\-]\w+)*\}/
74
- MeasurementRxt = '\\d+(?:\\.\\d+)?(?:in|cm|mm|pt|px)?'
75
- MeasurementPartsRx = /^(\d+(?:\.\d+)?)(in|mm|cm|pt|px)?$/
84
+ MeasurementRxt = '\\d+(?:\\.\\d+)?(?:in|cm|mm|p[txc])?'
85
+ MeasurementPartsRx = /^(\d+(?:\.\d+)?)(in|mm|cm|p[txc])?$/
76
86
  PageSizeRx = /^(?:\[(#{MeasurementRxt}), ?(#{MeasurementRxt})\]|(#{MeasurementRxt})(?: x |x)(#{MeasurementRxt})|\S+)$/
77
87
  # CalloutExtractRx synced from /lib/asciidoctor.rb of Asciidoctor core
78
88
  CalloutExtractRx = /(?:(?:\/\/|#|--|;;) ?)?(\\)?<!?(--|)(\d+)\2> ?(?=(?:\\?<!?\2\d+\2> ?)*$)/
79
89
  ImageAttributeValueRx = /^image:{1,2}(.*?)\[(.*?)\]$/
90
+ UriBreakCharsRx = /(?:\/|\?|&amp;|#)(?!$)/
91
+ UriBreakCharRepl = %(\\&#{ZeroWidthSpace})
92
+ UriSchemeBoundaryRx = /(?<=:\/\/)/
80
93
  LineScanRx = /\n|.+/
81
94
  BlankLineRx = /\n[[:blank:]]*\n/
82
95
  WhitespaceChars = %( \t\n)
83
96
  SourceHighlighters = ['coderay', 'pygments', 'rouge'].to_set
97
+ PygmentsBgColorRx = /^\.highlight +{ *background: *#([^;]+);/
84
98
  ViewportWidth = ::Module.new
85
99
 
86
100
  def initialize backend, opts
@@ -134,6 +148,21 @@ class Converter < ::Prawn::Document
134
148
  end
135
149
  #assign_missing_section_ids doc
136
150
 
151
+ # promote anonymous preface (defined using preamble block) to preface section
152
+ # FIXME this should be done in core
153
+ if doc.doctype == 'book' && (blk_0 = doc.blocks[0]) && blk_0.context == :preamble &&
154
+ blk_0.title? && blk_0.blocks[0].style != 'abstract' && (blk_1 = doc.blocks[1]) && blk_1.context == :section
155
+ preface = Section.new doc, blk_1.level, false, attributes: { 1 => 'preface', 'style' => 'preface' }
156
+ preface.special = true
157
+ preface.sectname = 'preface'
158
+ preface.title = doc.attr 'preface-title', 'Preface'
159
+ # QUESTION should ID be generated from raw or converted title? core is not clear about this
160
+ preface.id = preface.generate_id
161
+ preface.blocks.replace blk_0.blocks.map {|b| b.parent = preface; b }
162
+ doc.blocks[0] = preface
163
+ blk_0 = blk_1 = preface = nil
164
+ end
165
+
137
166
  # NOTE on_page_create is called within a float context
138
167
  # NOTE on_page_create is not called for imported pages, front and back cover pages, and other image pages
139
168
  on_page_create do
@@ -170,7 +199,7 @@ class Converter < ::Prawn::Document
170
199
  #start_new_page if @ppbook && verso_page?
171
200
  start_new_page if @media == 'prepress' && verso_page?
172
201
 
173
- num_front_matter_pages = page_number - 1
202
+ num_front_matter_pages = (@index.start_page_number = page_number) - 1
174
203
  font @theme.base_font_family, size: @theme.base_font_size, style: @theme.base_font_style.to_sym
175
204
  doc.set_attr 'pdf-anchor', (doc_anchor = derive_anchor_from_id doc.id, 'top')
176
205
  add_dest_for_block doc, doc_anchor
@@ -233,9 +262,11 @@ class Converter < ::Prawn::Document
233
262
  @page_bg_color = resolve_theme_color :page_background_color, 'FFFFFF'
234
263
  @fallback_fonts = [*theme.font_fallbacks]
235
264
  @font_color = theme.base_font_color
265
+ @base_align = (align = doc.attr 'text-alignment') && (TextAlignmentNames.include? align) ? align : theme.base_align
236
266
  @text_transform = nil
237
- # NOTE we have to init pdfmarks here while we have a reference to the doc
238
- @pdfmarks = (doc.attr? 'pdfmarks') ? (Pdfmarks.new doc) : nil
267
+ @index = IndexCatalog.new
268
+ # NOTE we have to init Pdfmark class here while we have reference to the doc
269
+ @pdfmark = (doc.attr? 'pdfmark') ? (Pdfmark.new doc) : nil
239
270
  init_scratch_prototype
240
271
  self
241
272
  end
@@ -246,7 +277,7 @@ class Converter < ::Prawn::Document
246
277
  #optimize_objects: true,
247
278
  info: (build_pdf_info doc),
248
279
  margin: theme.page_margin,
249
- page_layout: theme.page_layout.to_sym,
280
+ page_layout: ((doc.attr 'pdf-page-layout') || theme.page_layout).to_sym,
250
281
  skip_page_creation: true,
251
282
  }
252
283
 
@@ -280,13 +311,8 @@ class Converter < ::Prawn::Document
280
311
  # dimension cannot be less than 0
281
312
  dim > 0 ? dim : break
282
313
  elsif ::String === dim && (m = (MeasurementPartsRx.match dim))
283
- val = to_pt m[1].to_f, m[2]
284
- # NOTE 4 is the max practical precision in PDFs
285
- # QUESTION should we make rounding a feature of the to_pt method?
286
- if (val = val.round 4) == (i_val = val.to_i)
287
- val = i_val
288
- end
289
- val
314
+ # NOTE truncate to max precision retained by PDF::Core
315
+ (to_pt m[1].to_f, m[2]).truncate_to_precision 4
290
316
  else
291
317
  break
292
318
  end
@@ -303,30 +329,40 @@ class Converter < ::Prawn::Document
303
329
  def build_pdf_info doc
304
330
  info = {}
305
331
  # FIXME use sanitize: :plain_text once available
306
- info[:Title] = str2pdfval sanitize(doc.doctitle use_fallback: true)
332
+ info[:Title] = sanitize(doc.doctitle use_fallback: true).as_pdf
307
333
  if doc.attr? 'authors'
308
- info[:Author] = str2pdfval(doc.attr 'authors')
334
+ info[:Author] = (doc.attr 'authors').as_pdf
309
335
  end
310
336
  if doc.attr? 'subject'
311
- info[:Subject] = str2pdfval(doc.attr 'subject')
337
+ info[:Subject] = (doc.attr 'subject').as_pdf
312
338
  end
313
339
  if doc.attr? 'keywords'
314
- info[:Keywords] = str2pdfval(doc.attr 'keywords')
340
+ info[:Keywords] = (doc.attr 'keywords').as_pdf
315
341
  end
316
342
  if (doc.attr? 'publisher')
317
- info[:Producer] = str2pdfval(doc.attr 'publisher')
343
+ info[:Producer] = (doc.attr 'publisher').as_pdf
318
344
  end
319
- info[:Creator] = str2pdfval %(Asciidoctor PDF #{::Asciidoctor::Pdf::VERSION}, based on Prawn #{::Prawn::VERSION})
345
+ info[:Creator] = %(Asciidoctor PDF #{::Asciidoctor::Pdf::VERSION}, based on Prawn #{::Prawn::VERSION}).as_pdf
320
346
  info[:Producer] ||= (info[:Author] || info[:Creator])
321
- # FIXME use docdate attribute
322
- info[:ModDate] = info[:CreationDate] = ::Time.now unless doc.attr? 'reproducible'
347
+ unless doc.attr? 'reproducible'
348
+ # NOTE since we don't track the creation date of the input file, we map the ModDate header to the last modified
349
+ # date of the input document and the CreationDate header to the date the PDF was produced by the converter.
350
+ info[:ModDate] = ::Time.parse(doc.attr 'docdatetime') rescue (now ||= ::Time.now)
351
+ info[:CreationDate] = ::Time.parse(doc.attr 'localdatetime') rescue (now ||= ::Time.now)
352
+ end
323
353
  info
324
354
  end
325
355
 
326
356
  def convert_section sect, opts = {}
357
+ if sect.special && sect.sectname == 'abstract'
358
+ # HACK cheat a bit to hide this section from TOC; TOC should filter these sections
359
+ sect.context = :open
360
+ return convert_abstract sect
361
+ end
362
+
327
363
  theme_font :heading, level: (hlevel = sect.level + 1) do
328
364
  title = sect.numbered_title formal: true
329
- align = (@theme[%(heading_h#{hlevel}_align)] || @theme.heading_align || @theme.base_align).to_sym
365
+ align = (@theme[%(heading_h#{hlevel}_align)] || @theme.heading_align || @base_align).to_sym
330
366
  type = nil
331
367
  if sect.part_or_chapter?
332
368
  if sect.chapter?
@@ -355,7 +391,7 @@ class Converter < ::Prawn::Document
355
391
  end
356
392
  end
357
393
 
358
- convert_content_for_block sect
394
+ sect.special && sect.sectname == 'index' ? (convert_index_section sect) : (convert_content_for_block sect)
359
395
  sect.set_attr 'pdf-page-end', page_number
360
396
  end
361
397
 
@@ -363,13 +399,18 @@ class Converter < ::Prawn::Document
363
399
  add_dest_for_block node if node.id
364
400
  # QUESTION should we decouple styles from section titles?
365
401
  theme_font :heading, level: (hlevel = node.level + 1) do
366
- layout_heading node.title, align: (@theme[%(heading_h#{hlevel}_align)] || @theme.heading_align || @theme.base_align).to_sym
402
+ layout_heading node.title, align: (@theme[%(heading_h#{hlevel}_align)] || @theme.heading_align || @base_align).to_sym
367
403
  end
368
404
  end
369
405
 
370
406
  def convert_abstract node
371
407
  add_dest_for_block node if node.id
372
408
  pad_box @theme.abstract_padding do
409
+ if node.title?
410
+ theme_font :abstract_title do
411
+ layout_heading node.title, align: (@theme.abstract_title_align || @base_align).to_sym
412
+ end
413
+ end
373
414
  theme_font :abstract do
374
415
  prose_opts = { line_height: @theme.abstract_line_height }
375
416
  # FIXME control more first_line_options using theme
@@ -399,11 +440,11 @@ class Converter < ::Prawn::Document
399
440
  end
400
441
 
401
442
  def convert_preamble node
402
- # FIXME should only use lead for first paragraph
403
- # add lead role to first paragraph then delegate to convert_content_for_block
404
- theme_font :lead do
405
- convert_content_for_block node
443
+ # TODO find_by needs to support a depth argument
444
+ if (first_p = (node.find_by context: :paragraph)[0]) && first_p.parent == node
445
+ first_p.add_role 'lead'
406
446
  end
447
+ convert_content_for_block node
407
448
  end
408
449
 
409
450
  # TODO add prose around image logic (use role to add special logic for headshot)
@@ -441,58 +482,134 @@ class Converter < ::Prawn::Document
441
482
  end
442
483
  end
443
484
 
444
- # FIXME alignment of content is off
445
485
  def convert_admonition node
446
486
  add_dest_for_block node if node.id
447
487
  theme_margin :block, :top
448
- icons = node.document.attr? 'icons', 'font'
449
- label = icons ? (node.attr 'name').to_sym : node.caption.upcase
488
+ type = node.attr 'name'
489
+ label_align = (@theme.admonition_label_align || :center).to_sym
490
+ # TODO allow vertical_align to be a number
491
+ if (label_valign = (@theme.admonition_label_vertical_align || :middle).to_sym) == :middle
492
+ label_valign = :center
493
+ end
494
+ if (label_min_width = @theme.admonition_label_min_width)
495
+ label_min_width = label_min_width.to_f
496
+ end
497
+ icons = (node.document.attr? 'icons') ? (node.document.attr 'icons') : false
498
+ if icons == 'font' && !(node.attr? 'icon', nil, false)
499
+ icon_data = admonition_icon_data(label_text = type.to_sym)
500
+ label_width = label_min_width ? label_min_width : (icon_data[:size] * 1.5)
501
+ # NOTE icon_uri will consider icon attribute on node first, then type
502
+ elsif icons && ::File.readable?(icon_path = (node.icon_uri type))
503
+ icons = true
504
+ # TODO introduce @theme.admonition_image_width? or use size key from admonition_icon_<name>?
505
+ label_width = label_min_width ? label_min_width : 36.0
506
+ else
507
+ if icons
508
+ icons = false
509
+ warn %(asciidoctor: WARNING: admonition icon image not found or not readable: #{icon_path}) unless scratch?
510
+ end
511
+ label_text = node.caption
512
+ theme_font :admonition_label do
513
+ theme_font %(admonition_label_#{type}) do
514
+ if (transform = @text_transform) && transform != 'none'
515
+ label_text = transform_text label_text, transform
516
+ end
517
+ label_width = width_of label_text
518
+ label_width = label_min_width if label_min_width && label_min_width > label_width
519
+ end
520
+ end
521
+ end
522
+ unless ::Array === (cpad = @theme.admonition_padding)
523
+ cpad = ::Array.new 4, cpad
524
+ end
525
+ unless ::Array === (lpad = @theme.admonition_label_padding || cpad)
526
+ lpad = ::Array.new 4, lpad
527
+ end
450
528
  # FIXME this shift stuff is a real hack until we have proper margin collapsing
451
529
  shift_base = @theme.prose_margin_bottom
452
- #shift_top = icons ? (shift_base / 3.0) : 0
453
- #shift_bottom = icons ? ((shift_base * 2) / 3.0) : shift_base
454
530
  shift_top = shift_base / 3.0
455
531
  shift_bottom = (shift_base * 2) / 3.0
456
532
  keep_together do |box_height = nil|
457
- #theme_font :admonition do
458
- # FIXME this is a fudge calculation for the icon width
459
- label_width = icons ? (bounds.width / 12.0) : (width_of label)
460
- abs_left = bounds.absolute_left
461
- abs_right = bounds.absolute_right
462
- pad_box @theme.admonition_padding do
463
- left_padding = bounds.absolute_left - abs_left
464
- right_padding = abs_right - bounds.absolute_right
465
- if box_height
533
+ pad_box [0, cpad[1], 0, lpad[3]] do
534
+ if box_height
535
+ if (rule_color = @theme.admonition_column_rule_color) &&
536
+ (rule_width = @theme.admonition_column_rule_width || @theme.base_border_width) && rule_width > 0
466
537
  float do
467
- bounding_box [0, cursor], width: label_width + right_padding, height: box_height do
538
+ bounding_box [0, cursor], width: label_width + lpad[1], height: box_height do
539
+ stroke_vertical_rule rule_color,
540
+ at: bounds.width,
541
+ line_style: (@theme.admonition_column_rule_style || :solid).to_sym,
542
+ line_width: rule_width
543
+ end
544
+ end
545
+ end
546
+ float do
547
+ bounding_box [0, cursor], width: label_width, height: box_height do
548
+ if icons == 'font'
549
+ # FIXME we're assume icon is a square
550
+ icon_size = fit_icon_to_bounds icon_data[:size]
551
+ # NOTE Prawn's vertical center is not reliable, so calculate it manually
552
+ if label_valign == :center
553
+ label_valign = :top
554
+ if (vcenter_pos = (box_height - icon_size) * 0.5) > 0
555
+ move_down vcenter_pos
556
+ end
557
+ end
558
+ icon icon_data[:name],
559
+ valign: label_valign,
560
+ align: label_align,
561
+ color: icon_data[:stroke_color],
562
+ size: icon_size
563
+ elsif icons
564
+ begin
565
+ image_obj, image_info = build_image_object icon_path
566
+ icon_aspect_ratio = image_info.width.fdiv image_info.height
567
+ # NOTE don't scale image up if smaller than label_width
568
+ icon_width = [(to_pt image_info.width, :px), label_width].min
569
+ if (icon_height = icon_width * (1 / icon_aspect_ratio)) > box_height
570
+ icon_width *= box_height / icon_height
571
+ icon_height = box_height
572
+ end
573
+ embed_image image_obj, image_info, width: icon_width, position: label_align, vposition: label_valign
574
+ rescue => e
575
+ # QUESTION should we show the label in this case?
576
+ warn %(asciidoctor: WARNING: could not embed admonition icon image: #{icon_path}; #{e.message})
577
+ end
578
+ else
468
579
  # IMPORTANT the label must fit in the alotted space or it shows up on another page!
469
580
  # QUESTION anyway to prevent text overflow in the case it doesn't fit?
470
- stroke_vertical_rule @theme.admonition_border_color, at: bounds.width
471
- # FIXME HACK make title in this location look right
472
- label_margin_top = node.title? ? @theme.caption_margin_inside : 0
473
- if icons
474
- icon_data = admonition_icon_data label
475
- icon icon_data[:name], {
476
- valign: :center,
477
- align: :center,
478
- color: icon_data[:stroke_color],
479
- size: (fit_icon_size node, icon_data[:size])
480
- }
481
- else
482
- layout_prose label, valign: :center, style: :bold, line_height: 1, margin_top: label_margin_top, margin_bottom: 0
581
+ theme_font :admonition_label do
582
+ theme_font %(admonition_label_#{type}) do
583
+ # NOTE Prawn's vertical center is not reliable, so calculate it manually
584
+ if label_valign == :center
585
+ label_valign = :top
586
+ if (vcenter_pos = (box_height - (height_of_typeset_text label_text, line_height: 1)) * 0.5) > 0
587
+ move_down vcenter_pos
588
+ end
589
+ end
590
+ @text_transform = nil # already applied to label
591
+ layout_prose label_text,
592
+ align: label_align,
593
+ valign: label_valign,
594
+ line_height: 1,
595
+ margin: 0,
596
+ inline_format: false
597
+ end
483
598
  end
484
599
  end
485
600
  end
486
601
  end
487
- indent label_width + left_padding + right_padding do
488
- move_down shift_top
489
- layout_caption node.title if node.title?
602
+ end
603
+ pad_box [cpad[0], 0, cpad[2], label_width + lpad[1] + cpad[3]] do
604
+ move_down shift_top
605
+ layout_caption node.title if node.title?
606
+ theme_font :admonition do
490
607
  convert_content_for_block node
491
- # FIXME HACK compensate for margin bottom of admonition content
492
- move_up shift_bottom unless at_page_top?
493
608
  end
609
+ # FIXME HACK compensate for margin bottom of admonition content
610
+ move_up shift_bottom unless at_page_top?
494
611
  end
495
- #end
612
+ end
496
613
  end
497
614
  theme_margin :block, :bottom
498
615
  end
@@ -519,18 +636,15 @@ class Converter < ::Prawn::Document
519
636
  end
520
637
 
521
638
  def convert_open node
522
- case node.style
523
- when 'abstract'
639
+ if node.style == 'abstract'
524
640
  convert_abstract node
525
- when 'partintro'
526
- if node.blocks.size == 1 && node.blocks.first.style == 'abstract'
527
- convert_abstract node.blocks.first
528
- else
529
- add_dest_for_block node if node.id
530
- convert_content_for_block node
531
- end
641
+ elsif node.style == 'partintro' && node.blocks.size == 1 && node.blocks.first.style == 'abstract'
642
+ # TODO process block title and id
643
+ # TODO process abstract child even when partintro has multiple blocks
644
+ convert_abstract node.blocks.first
532
645
  else
533
646
  add_dest_for_block node if node.id
647
+ layout_caption node.title if node.title?
534
648
  convert_content_for_block node
535
649
  end
536
650
  end
@@ -543,6 +657,7 @@ class Converter < ::Prawn::Document
543
657
  keep_together do |box_height = nil|
544
658
  start_page_number = page_number
545
659
  start_cursor = cursor
660
+ caption_height = node.title? ? (layout_caption node) : 0
546
661
  pad_box @theme.blockquote_padding do
547
662
  theme_font :blockquote do
548
663
  if node.context == :quote
@@ -559,8 +674,9 @@ class Converter < ::Prawn::Document
559
674
  end
560
675
  end
561
676
  # FIXME we want to draw graphics before content, but box_height is not reliable when spanning pages
562
- if box_height
563
- page_spread = (end_page_number = page_number) - start_page_number + 1
677
+ # FIXME border extends to bottom of content area if block terminates at bottom of page
678
+ if box_height && b_width > 0
679
+ page_spread = page_number - start_page_number + 1
564
680
  end_cursor = cursor
565
681
  go_to_page start_page_number
566
682
  move_cursor_to start_cursor
@@ -573,9 +689,20 @@ class Converter < ::Prawn::Document
573
689
  y_draw = cursor
574
690
  b_height = page_spread - 1 == i ? (y_draw - end_cursor) : y_draw
575
691
  end
692
+ # NOTE skip past caption if present
693
+ if caption_height > 0
694
+ if caption_height > cursor
695
+ caption_height -= cursor
696
+ next # keep skipping, caption is on next page
697
+ end
698
+ y_draw -= caption_height
699
+ b_height -= caption_height
700
+ caption_height = 0
701
+ end
702
+ # NOTE b_height is 0 when block terminates at bottom of page
576
703
  bounding_box [0, y_draw], width: bounds.width, height: b_height do
577
704
  stroke_vertical_rule b_color, line_width: b_width, at: b_width / 2.0
578
- end
705
+ end unless b_height == 0
579
706
  end
580
707
  end
581
708
  end
@@ -600,7 +727,7 @@ class Converter < ::Prawn::Document
600
727
  if node.title?
601
728
  theme_font :sidebar_title do
602
729
  # QUESTION should we allow margins of sidebar title to be customized?
603
- layout_heading node.title, align: (@theme.sidebar_title_align || @theme.base_align).to_sym, margin_top: 0
730
+ layout_heading node.title, align: (@theme.sidebar_title_align || @base_align).to_sym, margin_top: 0
604
731
  end
605
732
  end
606
733
  theme_font :sidebar do
@@ -685,6 +812,7 @@ class Converter < ::Prawn::Document
685
812
  def convert_olist node
686
813
  add_dest_for_block node if node.id
687
814
  @list_numbers ||= []
815
+ # TODO move list_number resolve to a method
688
816
  list_number = case node.style
689
817
  when 'arabic'
690
818
  '1'
@@ -824,120 +952,103 @@ class Converter < ::Prawn::Document
824
952
  node.extend ::Asciidoctor::Image unless ::Asciidoctor::Image === node
825
953
  target, image_format = node.target_and_format
826
954
 
827
- if image_format == 'gif'
828
- warn %(asciidoctor: WARNING: GIF image format not supported. Please convert #{target} to PNG.) unless scratch?
955
+ if image_format == 'gif' && !(defined? ::GMagick::Image)
956
+ warn %(asciidoctor: WARNING: GIF image format not supported. Install the prawn-gmagick gem or convert #{target} to PNG.) unless scratch?
829
957
  image_path = false
958
+ elsif ::Base64 === target
959
+ image_path = target
830
960
  elsif (image_path = resolve_image_path node, target, (opts.fetch :relative_to_imagesdir, true), image_format) &&
831
961
  (::File.readable? image_path)
832
962
  # NOTE import_page automatically advances to next page afterwards
833
963
  # QUESTION should we add destination to top of imported page?
834
- return import_page image_path if image_format == 'pdf'
964
+ return import_page image_path, replace: page_is_empty? if image_format == 'pdf'
835
965
  else
836
966
  warn %(asciidoctor: WARNING: image to embed not found or not readable: #{image_path || target}) unless scratch?
837
- image_path = false
838
967
  # QUESTION should we use alt text in this case?
839
968
  return if image_format == 'pdf'
969
+ image_path = false
840
970
  end
841
971
 
842
- # QUESTION if we advance to new page, shouldn't dest point there too?
843
- add_dest_for_block node if node.id
844
- alignment = ((node.attr 'align', nil, false) || @theme.image_align).to_sym
972
+ theme_margin :block, :top unless (pinned = opts[:pinned])
845
973
 
846
- theme_margin :block, :top
974
+ return on_image_error :missing, node, target, opts unless image_path
847
975
 
848
- unless image_path
849
- if (link = node.attr 'link', nil, false)
850
- alt_text = %(<a href="#{link}">[#{NoBreakSpace}#{node.attr 'alt'}#{NoBreakSpace}]</a> | <em>#{target}</em>)
851
- else
852
- alt_text = %([#{NoBreakSpace}#{node.attr 'alt'}#{NoBreakSpace}] | <em>#{target}</em>)
853
- end
854
- layout_prose alt_text, normalize: false, margin: 0, single_line: true, align: alignment
855
- layout_caption node, side: :bottom if node.title?
856
- theme_margin :block, :bottom
857
- return
858
- end
976
+ # TODO move this calculation into a method, such as layout_caption node, side: :bottom, dry_run: true
977
+ caption_h = 0
978
+ dry_run do
979
+ move_down 0.0001 # hack to force top margin to be applied
980
+ # NOTE we assume caption fits on a single page, which seems reasonable
981
+ caption_h = layout_caption node, side: :bottom
982
+ end if node.title?
859
983
 
860
984
  # TODO support cover (aka canvas) image layout using "canvas" (or "cover") role
861
- width = resolve_explicit_width node.attributes, bounds.width, support_vw: true, use_fallback: true
862
- if (width_relative_to_page = ViewportWidth === width)
863
- width = (width.to_f / 100) * page_width
864
- overflow = [bounds_margin_left, bounds_margin_right]
865
- else
866
- overflow = 0
867
- end
985
+ width = resolve_explicit_width node.attributes, (available_w = bounds.width), support_vw: true, use_fallback: true
986
+ # TODO add `to_pt page_width` method to ViewportWidth type
987
+ width = (width.to_f / 100) * page_width if (width_relative_to_page = ViewportWidth === width)
988
+
989
+ alignment = ((node.attr 'align', nil, false) || @theme.image_align).to_sym
868
990
 
869
- span_page_width_if width_relative_to_page do
870
- case image_format
871
- when 'svg'
872
- begin
873
- svg_data = ::IO.read image_path
991
+ begin
992
+ span_page_width_if width_relative_to_page do
993
+ if image_format == 'svg'
994
+ if ::Base64 === image_path
995
+ svg_data = ::Base64.decode64 image_path
996
+ file_request_root = false
997
+ else
998
+ svg_data = ::IO.read image_path
999
+ file_request_root = ::File.dirname image_path
1000
+ end
874
1001
  svg_obj = ::Prawn::Svg::Interface.new svg_data, self,
875
1002
  position: alignment,
876
1003
  width: width,
877
- fallback_font_name: (fallback_font_name = default_svg_font),
878
- enable_web_requests: (enable_web_requests = node.document.attr? 'allow-uri-read'),
1004
+ fallback_font_name: default_svg_font,
1005
+ enable_web_requests: (node.document.attr? 'allow-uri-read'),
879
1006
  # TODO enforce jail in safe mode
880
- enable_file_requests_with_root: (file_request_root = ::File.dirname image_path)
1007
+ enable_file_requests_with_root: file_request_root
881
1008
  rendered_w = (svg_size = svg_obj.document.sizing).output_width
882
1009
  if !width && (svg_obj.document.root.attributes.key? 'width')
883
- # NOTE scale native width & height by 75% to convert px to pt; restrict width to bounds.width
884
- if (adjusted_w = [bounds.width, rendered_w * 0.75].min) != rendered_w
885
- # FIXME would be nice to have a resize method (available as of prawn-svg 0.25.2); for now, just reconstruct
886
- svg_obj = ::Prawn::Svg::Interface.new svg_data, self,
887
- position: alignment,
888
- width: (rendered_w = adjusted_w),
889
- fallback_font_name: fallback_font_name,
890
- enable_web_requests: enable_web_requests,
891
- enable_file_requests_with_root: file_request_root
892
- svg_size = svg_obj.document.sizing
1010
+ # NOTE scale native width & height from px to pt and restrict width to available width
1011
+ if (adjusted_w = [available_w, (to_pt rendered_w, :px)].min) != rendered_w
1012
+ svg_size = svg_obj.resize width: (rendered_w = adjusted_w)
893
1013
  end
894
1014
  end
895
- # TODO shrink image to fit on a single page if height exceeds page height
896
- rendered_h = svg_size.output_height
897
- # TODO layout SVG without using keep_together (since we know the dimensions already); always render caption
898
- keep_together do |box_height = nil|
899
- svg_obj.instance_variable_set :@prawn, self
900
- # NOTE prawn-svg 0.24.0, 0.25.0, & 0.25.1 didn't restore font after call to draw (see mogest/prawn-svg#80)
901
- svg_obj.draw
902
- if box_height && (link = node.attr 'link', nil, false)
903
- link_annotation [(abs_left = svg_obj.position[0] + bounds.absolute_left), y, (abs_left + rendered_w), (y + rendered_h)],
904
- Border: [0, 0, 0],
905
- A: { Type: :Action, S: :URI, URI: (str2pdfval link) }
1015
+ # NOTE shrink image so it fits within available space; group image & caption
1016
+ if (rendered_h = svg_size.output_height) > (available_h = cursor - caption_h)
1017
+ unless pinned || at_page_top?
1018
+ start_new_page
1019
+ available_h = cursor - caption_h
1020
+ end
1021
+ if rendered_h > available_h
1022
+ rendered_w = (svg_size = svg_obj.resize height: (rendered_h = available_h)).output_width
906
1023
  end
907
- indent(*overflow) do
908
- layout_caption node, side: :bottom
909
- end if node.title?
910
1024
  end
911
- rescue => e
912
- warn %(asciidoctor: WARNING: could not embed image: #{image_path}; #{e.message})
913
- end
914
- else
915
- begin
1025
+ add_dest_for_block node if node.id
1026
+ # NOTE workaround to fix Prawn not adding fill and stroke commands on page that only has an image;
1027
+ # breakage occurs when running content (stamps) are added to page
1028
+ update_colors if graphic_state.color_space.empty?
1029
+ # NOTE prawn-svg 0.24.0, 0.25.0, & 0.25.1 didn't restore font after call to draw (see mogest/prawn-svg#80)
1030
+ # NOTE cursor advanced automatically
1031
+ svg_obj.draw
1032
+ if (link = node.attr 'link', nil, false)
1033
+ link_box = [(abs_left = svg_obj.position[0] + bounds.absolute_left), y, (abs_left + rendered_w), (y + rendered_h)]
1034
+ link_annotation link_box, Border: [0, 0, 0], A: { Type: :Action, S: :URI, URI: link.as_pdf }
1035
+ end
1036
+ else
916
1037
  # FIXME this code really needs to be better organized!
917
- # FIXME temporary workaround to group caption & image
918
1038
  # NOTE use low-level API to access intrinsic dimensions; build_image_object caches image data previously loaded
919
- image_obj, image_info = ::File.open(image_path, 'rb') {|fd| build_image_object fd }
920
- if width
921
- rendered_w, rendered_h = image_info.calc_image_dimensions width: width
922
- else
923
- # NOTE scale native width & height by 75% to convert px to pt; restrict width to bounds.width
924
- rendered_w = [bounds.width, image_info.width * 0.75].min
925
- rendered_h = (rendered_w * image_info.height) / image_info.width
926
- end
927
- # TODO move this calculation into a method
928
- caption_height = node.title? ?
929
- (@theme.caption_margin_inside + @theme.caption_margin_outside + @theme.base_line_height_length) : 0
930
- if rendered_h > (available_height = cursor - caption_height)
931
- start_new_page unless at_page_top?
932
- # NOTE shrink image so it fits on a single page if height exceeds page height
933
- if rendered_h > (available_height = cursor - caption_height)
934
- rendered_w = (rendered_w * available_height) / rendered_h
935
- rendered_h = available_height
936
- # FIXME workaround to fix Prawn not adding fill and stroke commands
937
- # on page that only has an image; breakage occurs when line numbers are added
938
- # NOTE this no longer seems to be an issue
939
- fill_color self.fill_color
940
- stroke_color self.stroke_color
1039
+ image_obj, image_info = ::Base64 === image_path ?
1040
+ ::StringIO.open((::Base64.decode64 image_path), 'rb') {|fd| build_image_object fd } :
1041
+ ::File.open(image_path, 'rb') {|fd| build_image_object fd }
1042
+ # NOTE if width is not specified, scale native width & height from px to pt and restrict width to available width
1043
+ rendered_w, rendered_h = image_info.calc_image_dimensions width: (width || [available_w, (to_pt image_info.width, :px)].min)
1044
+ # NOTE shrink image so it fits within available space; group image & caption
1045
+ if rendered_h > (available_h = cursor - caption_h)
1046
+ unless pinned || at_page_top?
1047
+ start_new_page
1048
+ available_h = cursor - caption_h
1049
+ end
1050
+ if rendered_h > available_h
1051
+ rendered_w, rendered_h = image_info.calc_image_dimensions height: (rendered_h = available_h)
941
1052
  end
942
1053
  end
943
1054
  # NOTE must calculate link position before embedding to get proper boundaries
@@ -945,25 +1056,100 @@ class Converter < ::Prawn::Document
945
1056
  img_x, img_y = image_position rendered_w, rendered_h, position: alignment
946
1057
  link_box = [img_x, (img_y - rendered_h), (img_x + rendered_w), img_y]
947
1058
  end
948
- embed_image image_obj, image_info, width: rendered_w, position: alignment
949
- if link
950
- link_annotation link_box,
951
- Border: [0, 0, 0],
952
- A: { Type: :Action, S: :URI, URI: (str2pdfval link) }
953
- end
954
- rescue => e
955
- warn %(asciidoctor: WARNING: could not embed image: #{image_path}; #{e.message})
1059
+ image_top = cursor
1060
+ add_dest_for_block node if node.id
1061
+ # NOTE workaround to fix Prawn not adding fill and stroke commands on page that only has an image;
1062
+ # breakage occurs when running content (stamps) are added to page
1063
+ update_colors if graphic_state.color_space.empty?
1064
+ # NOTE specify both width and height to avoid recalculation
1065
+ embed_image image_obj, image_info, width: rendered_w, height: rendered_h, position: alignment
1066
+ link_annotation link_box, Border: [0, 0, 0], A: { Type: :Action, S: :URI, URI: link.as_pdf } if link
1067
+ # NOTE Asciidoctor disables automatic advancement of cursor for raster images, so move cursor manually
1068
+ move_down rendered_h if cursor == image_top
956
1069
  end
957
- indent(*overflow) do
958
- layout_caption node, side: :bottom
959
- end if node.title?
960
1070
  end
1071
+ layout_caption node, side: :bottom if node.title?
1072
+ theme_margin :block, :bottom unless pinned
1073
+ rescue => e
1074
+ on_image_error :exception, node, target, (opts.merge message: %(asciidoctor: WARNING: could not embed image: #{image_path}; #{e.message}))
961
1075
  end
962
- theme_margin :block, :bottom
963
1076
  ensure
964
1077
  unlink_tmp_file image_path if image_path
965
1078
  end
966
1079
 
1080
+ def on_image_error reason, node, target, opts = {}
1081
+ warn opts[:message] if opts.key? :message
1082
+ alt_text = (link = node.attr 'link', nil, false) ?
1083
+ %(<a href="#{link}">[#{node.attr 'alt'}]</a> | <em>#{target}</em>) :
1084
+ %([#{node.attr 'alt'}] | <em>#{target}</em>)
1085
+ layout_prose alt_text,
1086
+ align: ((node.attr 'align', nil, false) || @theme.image_align).to_sym,
1087
+ margin: 0,
1088
+ normalize: false,
1089
+ single_line: true
1090
+ layout_caption node, side: :bottom if node.title?
1091
+ theme_margin :block, :bottom unless opts[:pinned]
1092
+ nil
1093
+ end
1094
+
1095
+ def convert_audio node
1096
+ add_dest_for_block node if node.id
1097
+ theme_margin :block, :top
1098
+ audio_path = node.media_uri(node.attr 'target')
1099
+ play_symbol = (node.document.attr? 'icons', 'font') ?
1100
+ %(<font name="fa">#{::Prawn::Icon::FontData.load(self, 'fa').unicode 'play'}</font>) : RightPointer
1101
+ layout_prose %(#{play_symbol}#{NoBreakSpace}<a href="#{audio_path}">#{audio_path}</a> <em>(audio)</em>), normalize: false, margin: 0, single_line: true
1102
+ layout_caption node, side: :bottom if node.title?
1103
+ theme_margin :block, :bottom
1104
+ end
1105
+
1106
+ def convert_video node
1107
+ case (poster = node.attr 'poster', nil, false)
1108
+ when 'youtube'
1109
+ video_path = %(https://www.youtube.com/watch?v=#{video_id = node.attr 'target'})
1110
+ # see http://stackoverflow.com/questions/2068344/how-do-i-get-a-youtube-video-thumbnail-from-the-youtube-api
1111
+ poster = (node.document.attr? 'allow-uri-read') ? %(https://img.youtube.com/vi/#{video_id}/maxresdefault.jpg) : nil
1112
+ type = 'YouTube video'
1113
+ when 'vimeo'
1114
+ video_path = %(https://vimeo.com/#{video_id = node.attr 'target'})
1115
+ if node.document.attr? 'allow-uri-read'
1116
+ if node.document.attr? 'cache-uri'
1117
+ Helpers.require_library 'open-uri/cached', 'open-uri-cached' unless defined? ::OpenURI::Cache
1118
+ else
1119
+ ::OpenURI
1120
+ end
1121
+ poster = open(%(http://vimeo.com/api/v2/video/#{video_id}.xml), 'r') do |f|
1122
+ (/<thumbnail_large>(.*?)<\/thumbnail_large>/.match f.read)[1]
1123
+ end
1124
+ else
1125
+ poster = nil
1126
+ end
1127
+ type = 'Vimeo video'
1128
+ else
1129
+ video_path = node.media_uri(node.attr 'target')
1130
+ type = 'video'
1131
+ end
1132
+
1133
+ if poster.nil_or_empty?
1134
+ add_dest_for_block node if node.id
1135
+ theme_margin :block, :top
1136
+ play_symbol = (node.document.attr? 'icons', 'font') ?
1137
+ %(<font name="fa">#{::Prawn::Icon::FontData.load(self, 'fa').unicode 'play'}</font>) : RightPointer
1138
+ layout_prose %(#{play_symbol}#{NoBreakSpace}<a href="#{video_path}">#{video_path}</a> <em>(#{type})</em>), normalize: false, margin: 0, single_line: true
1139
+ layout_caption node, side: :bottom if node.title?
1140
+ theme_margin :block, :bottom
1141
+ else
1142
+ original_attributes = node.attributes.dup
1143
+ begin
1144
+ node.update_attributes 'target' => poster, 'link' => video_path
1145
+ #node.set_attr 'pdfwidth', '100%' unless (node.attr? 'width') || (node.attr? 'pdfwidth')
1146
+ convert_image node
1147
+ ensure
1148
+ node.attributes.replace original_attributes
1149
+ end
1150
+ end
1151
+ end
1152
+
967
1153
  # QUESTION can we avoid arranging fragments multiple times (conums & autofit) by eagerly preparing arranger?
968
1154
  def convert_listing_or_literal node
969
1155
  add_dest_for_block node if node.id
@@ -1001,6 +1187,8 @@ class Converter < ::Prawn::Document
1001
1187
  source_string = preserve_indentation node.content, (node.attr 'tabsize')
1002
1188
  end
1003
1189
 
1190
+ bg_color_override = nil
1191
+
1004
1192
  source_chunks = case highlighter
1005
1193
  when 'coderay'
1006
1194
  Helpers.require_library CodeRayRequirePath, 'coderay' unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
@@ -1010,13 +1198,28 @@ class Converter < ::Prawn::Document
1010
1198
  when 'pygments'
1011
1199
  Helpers.require_library 'pygments', 'pygments.rb' unless defined? ::Pygments
1012
1200
  lexer = ::Pygments::Lexer[node.attr 'language', 'text', false] || ::Pygments::Lexer['text']
1013
- pygments_config = { nowrap: true, noclasses: true, stripnl: false, style: (node.document.attr 'pygments-style') || 'pastie' }
1201
+ pygments_config = {
1202
+ nowrap: true,
1203
+ noclasses: true,
1204
+ stripnl: false,
1205
+ style: style = (node.document.attr 'pygments-style') || 'pastie'
1206
+ }
1014
1207
  # TODO enable once we support background color on spans
1015
1208
  #if node.attr? 'highlight', nil, false
1016
1209
  # unless (hl_lines = node.resolve_lines_to_highlight(node.attr 'highlight', nil, false)).empty?
1017
1210
  # pygments_config[:hl_lines] = hl_lines * ' '
1018
1211
  # end
1019
1212
  #end
1213
+ # QUESTION should we treat white background as inherit?
1214
+ # QUESTION allow border color to be set by theme for highlighted block?
1215
+ if (node.document.attr? 'pygments-bgcolor')
1216
+ bg_color_override = node.document.attr 'pygments-bgcolor'
1217
+ elsif style == 'pastie'
1218
+ node.document.set_attr 'pygments-bgcolor', (bg_color_override = nil)
1219
+ else
1220
+ node.document.set_attr 'pygments-bgcolor',
1221
+ (bg_color_override = PygmentsBgColorRx =~ (::Pygments.css '.highlight', style: style) ? $1 : nil)
1222
+ end
1020
1223
  source_string, conum_mapping = extract_conums source_string
1021
1224
  # NOTE pygments.rb strips trailing whitespace; preserve it in case there are conums on last line
1022
1225
  num_trailing_spaces = source_string.size - (source_string = source_string.rstrip).size if conum_mapping
@@ -1026,7 +1229,9 @@ class Converter < ::Prawn::Document
1026
1229
  when 'rouge'
1027
1230
  Helpers.require_library RougeRequirePath, 'rouge' unless defined? ::Rouge::Formatters::Prawn
1028
1231
  lexer = ::Rouge::Lexer.find(node.attr 'language', 'text', false) || ::Rouge::Lexers::PlainText
1029
- formatter = (@rouge_formatter ||= ::Rouge::Formatters::Prawn.new theme: (node.document.attr 'rouge-style'))
1232
+ formatter = (@rouge_formatter ||= ::Rouge::Formatters::Prawn.new theme: (node.document.attr 'rouge-style'), line_gap: @theme.code_line_gap)
1233
+ # QUESTION allow border color to be set by theme for highlighted block?
1234
+ bg_color_override = formatter.background_color
1030
1235
  source_string, conum_mapping = extract_conums source_string
1031
1236
  # NOTE trailing endline is added to address https://github.com/jneen/rouge/issues/279
1032
1237
  fragments = formatter.format (lexer.lex %(#{source_string}#{LF})), line_numbers: (node.attr? 'linenums')
@@ -1057,7 +1262,7 @@ class Converter < ::Prawn::Document
1057
1262
  # TODO move the multi-page logic to theme_fill_and_stroke_bounds
1058
1263
  unless (b_width = @theme.code_border_width || 0) == 0
1059
1264
  b_radius = (@theme.code_border_radius || 0) + b_width
1060
- bg_color = @theme.code_background_color || @page_bg_color
1265
+ b_gap_color = bg_color_override || @theme.code_background_color || @page_bg_color
1061
1266
  end
1062
1267
  remaining_height = box_height - caption_height
1063
1268
  i = 0
@@ -1065,17 +1270,17 @@ class Converter < ::Prawn::Document
1065
1270
  start_new_page if (started_new_page = i > 0)
1066
1271
  fill_height = [remaining_height, cursor].min
1067
1272
  bounding_box [0, cursor], width: bounds.width, height: fill_height do
1068
- theme_fill_and_stroke_bounds :code
1273
+ theme_fill_and_stroke_bounds :code, background_color: bg_color_override
1069
1274
  unless b_width == 0
1070
1275
  indent b_radius, b_radius do
1071
1276
  # dashed line to indicate continuation from previous page
1072
- stroke_horizontal_rule bg_color, line_width: b_width, line_style: :dashed
1277
+ stroke_horizontal_rule b_gap_color, line_width: b_width, line_style: :dashed
1073
1278
  end if started_new_page
1074
1279
  if remaining_height > fill_height
1075
1280
  move_down fill_height
1076
1281
  indent b_radius, b_radius do
1077
1282
  # dashed line to indicate continuation on next page
1078
- stroke_horizontal_rule bg_color, line_width: b_width, line_style: :dashed
1283
+ stroke_horizontal_rule b_gap_color, line_width: b_width, line_style: :dashed
1079
1284
  end
1080
1285
  end
1081
1286
  end
@@ -1207,11 +1412,13 @@ class Converter < ::Prawn::Document
1207
1412
  even_row_bg_color = resolve_theme_color :table_even_row_background_color, tbl_bg_color
1208
1413
 
1209
1414
  table_data = []
1210
- node.rows[:head].each do |rows|
1415
+ node.rows[:head].each do |row|
1211
1416
  table_header = true
1212
- head_transform = theme.table_head_text_transform
1417
+ if (head_transform = theme.table_head_text_transform)
1418
+ head_transform = nil if head_transform == 'none'
1419
+ end
1213
1420
  row_data = []
1214
- rows.each do |cell|
1421
+ row.each do |cell|
1215
1422
  row_data << {
1216
1423
  content: (head_transform ? (transform_text cell.text, head_transform) : cell.text),
1217
1424
  inline_format: [normalize: true],
@@ -1223,15 +1430,15 @@ class Converter < ::Prawn::Document
1223
1430
  colspan: cell.colspan || 1,
1224
1431
  rowspan: cell.rowspan || 1,
1225
1432
  align: (cell.attr 'halign', nil, false).to_sym,
1226
- valign: (cell.attr 'valign', nil, false).to_sym
1433
+ valign: (val = cell.attr 'valign', nil, false) == 'middle' ? :center : val.to_sym
1227
1434
  }
1228
1435
  end
1229
1436
  table_data << row_data
1230
1437
  end
1231
1438
 
1232
- (node.rows[:body] + node.rows[:foot]).each do |rows|
1439
+ (node.rows[:body] + node.rows[:foot]).each do |row|
1233
1440
  row_data = []
1234
- rows.each do |cell|
1441
+ row.each do |cell|
1235
1442
  cell_data = {
1236
1443
  text_color: (theme.table_font_color || @font_color),
1237
1444
  size: theme.table_font_size,
@@ -1250,25 +1457,22 @@ class Converter < ::Prawn::Document
1250
1457
  unless defined? header_cell_data
1251
1458
  header_cell_data = {}
1252
1459
  [
1460
+ # TODO honor text_transform key
1253
1461
  # QUESTION should we honor alignment set by col/cell spec? how can we tell?
1254
- ['align', :align, true],
1462
+ #['align', :align, true],
1255
1463
  ['font_color', :text_color, false],
1256
1464
  ['font_family', :font, false],
1257
1465
  ['font_size', :size, false],
1258
1466
  ['font_style', :font_style, true]
1259
1467
  ].each do |(theme_key, data_key, symbol_value)|
1260
- if (val = theme[%(table_header_cell_#{theme_key})])
1468
+ if (val = theme[%(table_header_cell_#{theme_key})] || theme[%(table_head_#{theme_key})])
1261
1469
  header_cell_data[data_key] = symbol_value ? val.to_sym : val
1262
1470
  end
1263
1471
  end
1264
- unless (header_cell_data.key? :font_style) || !(val = theme.table_head_font_style)
1265
- header_cell_data[:font_style] = val.to_sym
1266
- end
1267
- if (val = resolve_theme_color :table_header_cell_background_color)
1472
+ if (val = resolve_theme_color :table_header_cell_background_color, head_bg_color)
1268
1473
  header_cell_data[:background_color] = val
1269
1474
  end
1270
1475
  end
1271
-
1272
1476
  cell_data.update header_cell_data unless header_cell_data.empty?
1273
1477
  when :monospaced
1274
1478
  cell_data[:font] = theme.literal_font_family
@@ -1298,12 +1502,14 @@ class Converter < ::Prawn::Document
1298
1502
  cell_data[:content] = preserve_indentation cell.text, (node.document.attr 'tabsize')
1299
1503
  cell_data[:inline_format] = true
1300
1504
  when :asciidoc
1301
- # TODO finish me
1505
+ asciidoc_cell = ::Prawn::Table::Cell::AsciiDoc.new self,
1506
+ (cell_data.merge content: cell.inner_document, font_style: (val = theme.table_font_style) ? val.to_sym : nil)
1507
+ cell_data = { content: asciidoc_cell }
1302
1508
  else
1303
1509
  cell_data[:font_style] = (val = theme.table_font_style) ? val.to_sym : nil
1304
1510
  end
1305
1511
  unless cell_data.key? :content
1306
- # NOTE effectively the same as calling cell.content
1512
+ # NOTE effectively the same as calling cell.content (should we use that instead?)
1307
1513
  # TODO hard breaks not quite the same result as separate paragraphs; need custom cell impl
1308
1514
  if (cell_text = cell.text).include? LF
1309
1515
  cell_data[:content] = cell_text.split(BlankLineRx).map {|l| l.tr_s(WhitespaceChars, ' ') }.join(DoubleLF)
@@ -1318,10 +1524,17 @@ class Converter < ::Prawn::Document
1318
1524
  table_data << row_data
1319
1525
  end
1320
1526
 
1321
- table_data = [[{ content: '' }]] if table_data.empty?
1527
+ # NOTE Prawn aborts if table data is empty, so ensure there's at least one row
1528
+ if table_data.empty?
1529
+ empty_row = []
1530
+ node.columns.each do
1531
+ empty_row << { content: '' }
1532
+ end
1533
+ table_data = [empty_row]
1534
+ end
1322
1535
 
1323
1536
  border = {}
1324
- table_border_color = theme.table_border_color
1537
+ table_border_color = theme.table_border_color || table_grid_color || theme.base_border_color
1325
1538
  table_border_width = theme.table_border_width
1326
1539
  table_grid_width = theme.table_grid_width || theme.table_border_width
1327
1540
  [:top, :bottom, :left, :right].each {|edge| border[edge] = table_border_width }
@@ -1362,8 +1575,8 @@ class Converter < ::Prawn::Document
1362
1575
  end
1363
1576
  end
1364
1577
 
1365
- if ((alignment = node.attr 'align', nil, false) && (AlignmentNames.include? alignment)) ||
1366
- (alignment = (node.roles & AlignmentNames).last)
1578
+ if ((alignment = node.attr 'align', nil, false) && (BlockAlignmentNames.include? alignment)) ||
1579
+ (alignment = (node.roles & BlockAlignmentNames).last)
1367
1580
  alignment = alignment.to_sym
1368
1581
  else
1369
1582
  alignment = :left
@@ -1378,7 +1591,7 @@ class Converter < ::Prawn::Document
1378
1591
  padding: theme.table_cell_padding,
1379
1592
  border_width: 0,
1380
1593
  # NOTE the border color of edges is set later
1381
- border_color: theme.table_grid_color || theme.table_border_color
1594
+ border_color: theme.table_grid_color || theme.table_border_color || theme.base_border_color
1382
1595
  },
1383
1596
  width: table_width,
1384
1597
  column_widths: column_widths,
@@ -1388,7 +1601,7 @@ class Converter < ::Prawn::Document
1388
1601
  theme_margin :block, :top
1389
1602
 
1390
1603
  table table_data, table_settings do
1391
- # NOTE capture resolved table width
1604
+ # NOTE call width to capture resolved table width
1392
1605
  table_width = width
1393
1606
  @pdf.layout_table_caption node, table_width, alignment if node.title? && caption_side == :top
1394
1607
  if grid == 'none' && frame == 'none'
@@ -1440,7 +1653,7 @@ class Converter < ::Prawn::Document
1440
1653
  foot_row.font = theme.table_foot_font_family if theme.table_foot_font_family
1441
1654
  foot_row.font_style = theme.table_foot_font_style.to_sym if theme.table_foot_font_style
1442
1655
  # HACK we should do this transformation when creating the cell
1443
- #if (foot_transform = theme.table_foot_text_transform)
1656
+ #if (foot_transform = theme.table_foot_text_transform) && foot_transform != 'none'
1444
1657
  # foot_row.each {|c| c.content = (transform_text c.content, foot_transform) if c.content }
1445
1658
  #end
1446
1659
  end
@@ -1468,6 +1681,52 @@ class Converter < ::Prawn::Document
1468
1681
  start_new_page unless at_page_top?
1469
1682
  end
1470
1683
 
1684
+ def convert_index_section node
1685
+ unless @index.empty?
1686
+ space_needed_for_category = @theme.description_list_term_spacing + (2 * (height_of_typeset_text 'A'))
1687
+ column_box [0, cursor], columns: 2, width: bounds.width do
1688
+ @index.categories.each do |category|
1689
+ # NOTE cursor method always returns 0 inside column_box; breaks reference_bounds.move_past_bottom
1690
+ bounds.move_past_bottom if space_needed_for_category > y - reference_bounds.absolute_bottom
1691
+ layout_prose category.name,
1692
+ align: :left,
1693
+ inline_format: false,
1694
+ margin_top: 0,
1695
+ margin_bottom: @theme.description_list_term_spacing,
1696
+ style: @theme.description_list_term_font_style.to_sym
1697
+ category.terms.each do |term|
1698
+ convert_index_list_item term
1699
+ end
1700
+ if @theme.prose_margin_bottom > y - reference_bounds.absolute_bottom
1701
+ bounds.move_past_bottom
1702
+ else
1703
+ move_down @theme.prose_margin_bottom
1704
+ end
1705
+ end
1706
+ end
1707
+ end
1708
+ nil
1709
+ end
1710
+
1711
+ def convert_index_list_item term
1712
+ text = term.name
1713
+ unless term.container?
1714
+ if @media == 'screen'
1715
+ pagenums = term.dests.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
1716
+ else
1717
+ pagenums = term.dests.uniq {|dest| dest[:page] }.map {|dest| dest[:page].to_s }
1718
+ end
1719
+ text = %(#{escape_xml text}, #{pagenums * ', '})
1720
+ end
1721
+ layout_prose text, align: :left, margin: 0
1722
+
1723
+ term.subterms.each do |subterm|
1724
+ indent @theme.description_list_description_indent do
1725
+ convert_index_list_item subterm
1726
+ end
1727
+ end unless term.leaf?
1728
+ end
1729
+
1471
1730
  def convert_inline_anchor node
1472
1731
  case node.type
1473
1732
  when :link
@@ -1478,11 +1737,14 @@ class Converter < ::Prawn::Document
1478
1737
  end
1479
1738
  #attrs << %( title="#{node.attr 'title'}") if node.attr? 'title'
1480
1739
  attrs << %( target="#{node.attr 'window'}") if node.attr? 'window', nil, false
1740
+ if (role = node.attr 'role', nil, false) && (role == 'bare' || ((role.split ' ').include? 'bare'))
1741
+ # QUESTION should we insert breakable chars into URI when building fragment instead?
1742
+ %(<a href="#{node.target}"#{attrs.join}>#{breakable_uri node.text}</a>)
1481
1743
  # NOTE @media may not be initialized if method is called before convert phase
1482
- if ((@media ||= node.document.attr 'media', 'screen') != 'screen' || (node.document.attr? 'show-link-uri')) &&
1483
- !(node.has_role? 'bare')
1484
- # TODO allow style of visible link to be controlled by theme
1485
- %(<a href="#{target = node.target}"#{attrs.join}>#{node.text}</a> [<font size="0.85em">#{target}</font>])
1744
+ elsif (@media ||= node.document.attr 'media', 'screen') != 'screen' || (node.document.attr? 'show-link-uri')
1745
+ # QUESTION should we insert breakable chars into URI when building fragment instead?
1746
+ # TODO allow style of printed link to be controlled by theme
1747
+ %(<a href="#{target = node.target}"#{attrs.join}>#{node.text}</a> [<font size="0.85em">#{breakable_uri target}</font>])
1486
1748
  else
1487
1749
  %(<a href="#{node.target}"#{attrs.join}>#{node.text}</a>)
1488
1750
  end
@@ -1511,12 +1773,10 @@ class Converter < ::Prawn::Document
1511
1773
  end
1512
1774
  when :ref
1513
1775
  # NOTE destination is created inside callback registered by FormattedTextTransform#build_fragment
1514
- #%(<a name="#{node.target}"></a>)
1515
- %(<a name="#{node.target}">#{ZeroWidthSpace}</a>)
1776
+ %(<a name="#{node.target}">#{DummyText}</a>)
1516
1777
  when :bibref
1517
1778
  # NOTE destination is created inside callback registered by FormattedTextTransform#build_fragment
1518
- #%(<a name="#{target = node.target}"></a>[#{target}])
1519
- %(<a name="#{target = node.target}">#{ZeroWidthSpace}</a>[#{target}])
1779
+ %(<a name="#{target = node.target}">#{DummyText}</a>[#{target}])
1520
1780
  else
1521
1781
  warn %(asciidoctor: WARNING: unknown anchor type: #{node.type.inspect})
1522
1782
  end
@@ -1581,12 +1841,13 @@ class Converter < ::Prawn::Document
1581
1841
  else
1582
1842
  node.extend ::Asciidoctor::Image unless ::Asciidoctor::Image === node
1583
1843
  target, image_format = node.target_and_format
1584
- if image_format == 'gif'
1585
- warn %(asciidoctor: WARNING: GIF image format not supported. Please convert #{target} to PNG.) unless scratch?
1844
+ if image_format == 'gif' && !(defined? ::GMagick::Image)
1845
+ warn %(asciidoctor: WARNING: GIF image format not supported. Install the prawn-gmagick gem or convert #{target} to PNG.) unless scratch?
1586
1846
  img = %([#{node.attr 'alt'}])
1847
+ # NOTE an image with a data URI is handled using a temporary file
1587
1848
  elsif (image_path = resolve_image_path node, target, true, image_format) && (::File.readable? image_path)
1588
- width_attr = (node.attr? 'width', nil, false) ? %( width="#{node.attr 'width'}") : nil
1589
- img = %(<img src="#{image_path}" format="#{image_format}" alt="#{node.attr 'alt'}"#{width_attr} tmp="#{TemporaryPath === image_path}">)
1849
+ width_attr = (width = preresolve_explicit_width node.attributes) ? %( width="#{width}") : nil
1850
+ img = %(<img src="#{image_path}" format="#{image_format}" alt="[#{node.attr 'alt'}]"#{width_attr} tmp="#{TemporaryPath === image_path}">)
1590
1851
  else
1591
1852
  warn %(asciidoctor: WARNING: image to embed not found or not readable: #{image_path || target}) unless scratch?
1592
1853
  img = %([#{node.attr 'alt'}])
@@ -1596,7 +1857,24 @@ class Converter < ::Prawn::Document
1596
1857
  end
1597
1858
 
1598
1859
  def convert_inline_indexterm node
1599
- node.type == :visible ? node.text : nil
1860
+ # NOTE indexterms not supported if text gets substituted before PDF is initialized
1861
+ return '' unless instance_variable_defined? :@index
1862
+ if scratch?
1863
+ node.type == :visible ? node.text : ''
1864
+ else
1865
+ dest = {
1866
+ anchor: (anchor_name = %(__indexterm-#{node.object_id}))
1867
+ # NOTE page number is added in InlineDestinationMarker
1868
+ }
1869
+ anchor = %(<a name="#{anchor_name}" type="indexterm">#{DummyText}</a>)
1870
+ if node.type == :visible
1871
+ @index.store_primary_term(sanitize(visible_term = node.text), dest)
1872
+ %(#{anchor}#{visible_term})
1873
+ else
1874
+ @index.store_term((node.attr 'terms').map {|term| sanitize term }, dest)
1875
+ anchor
1876
+ end
1877
+ end
1600
1878
  end
1601
1879
 
1602
1880
  def convert_inline_kbd node
@@ -1651,7 +1929,7 @@ class Converter < ::Prawn::Document
1651
1929
  end
1652
1930
 
1653
1931
  # NOTE destination is created inside callback registered by FormattedTextTransform#build_fragment
1654
- node.id ? %(<a name="#{node.id}">#{ZeroWidthSpace}</a>#{quoted_text}) : quoted_text
1932
+ node.id ? %(<a name="#{node.id}">#{DummyText}</a>#{quoted_text}) : quoted_text
1655
1933
  end
1656
1934
 
1657
1935
  # FIXME only create title page if doctype=book!
@@ -1676,8 +1954,8 @@ class Converter < ::Prawn::Document
1676
1954
  # IMPORTANT this is the first page created, so we need to set the base font
1677
1955
  font @theme.base_font_family, size: @theme.base_font_size
1678
1956
 
1679
- # QUESTION allow aligment per element on title page?
1680
- title_align = (@theme.title_page_align || @theme.base_align).to_sym
1957
+ # QUESTION allow alignment per element on title page?
1958
+ title_align = (@theme.title_page_align || @base_align).to_sym
1681
1959
 
1682
1960
  # TODO disallow .pdf as image type
1683
1961
  if (logo_image_path = (doc.attr 'title-logo-image', @theme.title_page_logo_image))
@@ -1703,14 +1981,12 @@ class Converter < ::Prawn::Document
1703
1981
  else
1704
1982
  logo_image_top = bounds.absolute_top - effective_page_height * logo_image_top.to_f / 100.0
1705
1983
  end
1706
- float do
1707
- @y = logo_image_top
1708
- # FIXME add API to Asciidoctor for creating blocks like this (extract from extensions module?)
1709
- image_block = ::Asciidoctor::Block.new doc, :image, content_model: :empty, attributes: logo_image_attrs
1710
- # FIXME prevent image from spilling to next page
1711
- # QUESTION should we shave off margin top/bottom?
1712
- convert_image image_block, relative_to_imagesdir: relative_to_imagesdir
1713
- end
1984
+ initial_y, @y = @y, logo_image_top
1985
+ # FIXME add API to Asciidoctor for creating blocks like this (extract from extensions module?)
1986
+ image_block = ::Asciidoctor::Block.new doc, :image, content_model: :empty, attributes: logo_image_attrs
1987
+ # NOTE pinned option keeps image on same page
1988
+ convert_image image_block, relative_to_imagesdir: relative_to_imagesdir, pinned: true
1989
+ @y = initial_y
1714
1990
  end
1715
1991
 
1716
1992
  # TODO prevent content from spilling to next page
@@ -1745,20 +2021,23 @@ class Converter < ::Prawn::Document
1745
2021
  end
1746
2022
  if doc.attr? 'authors'
1747
2023
  move_down (@theme.title_page_authors_margin_top || 0)
2024
+ # TODO provide an API in core to get authors as an array
2025
+ authors = (1..(doc.attr 'authorcount', 1).to_i).map {|idx|
2026
+ doc.attr(idx == 1 ? 'author' : %(author_#{idx}))
2027
+ } * (@theme.title_page_authors_delimiter || ', ')
1748
2028
  theme_font :title_page_authors do
1749
- # TODO add support for author delimiter
1750
- layout_prose((doc.attr 'authors'),
2029
+ layout_prose authors,
1751
2030
  align: title_align,
1752
2031
  margin: 0,
1753
- normalize: false)
2032
+ normalize: false
1754
2033
  end
1755
2034
  move_down (@theme.title_page_authors_margin_bottom || 0)
1756
2035
  end
1757
2036
  revision_info = [(doc.attr? 'revnumber') ? %(#{doc.attr 'version-label'} #{doc.attr 'revnumber'}) : nil, (doc.attr 'revdate')].compact
1758
2037
  unless revision_info.empty?
1759
2038
  move_down (@theme.title_page_revision_margin_top || 0)
2039
+ revision_text = revision_info * (@theme.title_page_revision_delimiter || ', ')
1760
2040
  theme_font :title_page_revision do
1761
- revision_text = revision_info * (@theme.title_page_revision_delimiter || ', ')
1762
2041
  layout_prose revision_text,
1763
2042
  align: title_align,
1764
2043
  margin: 0,
@@ -1813,14 +2092,14 @@ class Converter < ::Prawn::Document
1813
2092
  def layout_heading string, opts = {}
1814
2093
  top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || @theme.heading_margin_top
1815
2094
  bot_margin = margin || (opts.delete :margin_bottom) || @theme.heading_margin_bottom
1816
- if (transform = (opts.delete :text_transform) || @text_transform)
2095
+ if (transform = (opts.delete :text_transform) || @text_transform) && transform != 'none'
1817
2096
  string = transform_text string, transform
1818
2097
  end
1819
2098
  margin_top top_margin
1820
2099
  typeset_text string, calc_line_metrics((opts.delete :line_height) || @theme.heading_line_height), {
1821
2100
  color: @font_color,
1822
2101
  inline_format: true,
1823
- align: @theme.base_align.to_sym
2102
+ align: @base_align.to_sym
1824
2103
  }.merge(opts)
1825
2104
  margin_bottom bot_margin
1826
2105
  end
@@ -1829,24 +2108,19 @@ class Converter < ::Prawn::Document
1829
2108
  def layout_prose string, opts = {}
1830
2109
  top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || @theme.prose_margin_top
1831
2110
  bot_margin = margin || (opts.delete :margin_bottom) || @theme.prose_margin_bottom
1832
- if (transform = (opts.delete :text_transform) || @text_transform)
2111
+ if (transform = (opts.delete :text_transform) || @text_transform) && transform != 'none'
1833
2112
  string = transform_text string, transform
1834
2113
  end
2114
+ # NOTE used by extensions; ensures linked text gets formatted using the link styles
1835
2115
  if (anchor = opts.delete :anchor)
1836
- # FIXME won't work if inline_format is true; should instead pass through as attribute w/ link color set
1837
- if (link_color = opts.delete :link_color)
1838
- # NOTE CMYK value gets flattened here, but is restored by formatted text parser
1839
- string = %(<a anchor="#{anchor}"><color rgb="#{link_color}">#{string}</color></a>)
1840
- else
1841
- string = %(<a anchor="#{anchor}">#{string}</a>)
1842
- end
2116
+ string = %(<a anchor="#{anchor}">#{string}</a>)
1843
2117
  end
1844
2118
  margin_top top_margin
1845
2119
  typeset_text string, calc_line_metrics((opts.delete :line_height) || @theme.base_line_height), {
1846
2120
  color: @font_color,
1847
2121
  # NOTE normalize makes endlines soft (replaces "\n" with ' ')
1848
2122
  inline_format: [normalize: (opts.delete :normalize) != false],
1849
- align: @theme.base_align.to_sym
2123
+ align: @base_align.to_sym
1850
2124
  }.merge(opts)
1851
2125
  margin_bottom bot_margin
1852
2126
  end
@@ -1873,7 +2147,7 @@ class Converter < ::Prawn::Document
1873
2147
  layout_prose string, {
1874
2148
  margin_top: margin[:top],
1875
2149
  margin_bottom: margin[:bottom],
1876
- align: (@theme.caption_align || @theme.base_align).to_sym,
2150
+ align: (@theme.caption_align || @base_align).to_sym,
1877
2151
  normalize: false
1878
2152
  }.merge(opts)
1879
2153
  if side == :top && @theme.caption_border_bottom_color
@@ -1907,17 +2181,33 @@ class Converter < ::Prawn::Document
1907
2181
  go_to_page toc_page_number unless (page_number == toc_page_number) || scratch?
1908
2182
  start_page_number = page_number
1909
2183
  theme_font :heading, level: 2 do
1910
- layout_heading((doc.attr 'toc-title'), align: (@theme.toc_title_align || @theme.base_align).to_sym)
2184
+ theme_font :toc_title do
2185
+ toc_title_align = (@theme.toc_title_align || @theme.heading_h2_align || @theme.heading_align || @base_align).to_sym
2186
+ layout_heading((doc.attr 'toc-title'), align: toc_title_align)
2187
+ end
1911
2188
  end
1912
- # QUESTION shouldn't we skip this whole method if num_levels == 0?
2189
+ # QUESTION should we skip this whole method if num_levels == 0?
1913
2190
  if num_levels > 0
1914
- theme_margin :toc, :top
1915
- line_metrics = calc_line_metrics @theme.toc_line_height
1916
- dot_width = nil
1917
- theme_font :toc do
1918
- dot_width = width_of(@theme.toc_dot_leader_content || DotLeaderDefault)
2191
+ dot_leader = theme_font :toc do
2192
+ # TODO we could simplify by using nested theme_font :toc_dot_leader
2193
+ if (dot_leader_font_style = (@theme.toc_dot_leader_font_style || :normal).to_sym) != font_style
2194
+ font_style dot_leader_font_style
2195
+ end
2196
+ {
2197
+ font_color: @theme.toc_dot_leader_font_color || @font_color,
2198
+ font_style: dot_leader_font_style,
2199
+ levels: ((dot_leader_l = @theme.toc_dot_leader_levels) == 'none' ? ::Set.new :
2200
+ (dot_leader_l && dot_leader_l != 'all' ? dot_leader_l.to_s.split.map(&:to_i).to_set : (1..num_levels).to_set)),
2201
+ text: (dot_leader_text = @theme.toc_dot_leader_content || DotLeaderTextDefault),
2202
+ width: dot_leader_text.empty? ? 0 : (width_of dot_leader_text),
2203
+ # TODO spacer gives a little bit of room between dots and page number
2204
+ spacer: { text: NoBreakSpace, size: (spacer_font_size = @font_size * 0.25) },
2205
+ spacer_width: (width_of NoBreakSpace, size: spacer_font_size)
2206
+ }
1919
2207
  end
1920
- layout_toc_level doc.sections, num_levels, line_metrics, dot_width, num_front_matter_pages
2208
+ line_metrics = calc_line_metrics @theme.toc_line_height
2209
+ theme_margin :toc, :top
2210
+ layout_toc_level doc.sections, num_levels, line_metrics, dot_leader, num_front_matter_pages
1921
2211
  end
1922
2212
  # NOTE range must be calculated relative to toc_page_number; absolute page number in scratch document is arbitrary
1923
2213
  toc_page_numbers = (toc_page_number..(toc_page_number + (page_number - start_page_number)))
@@ -1925,51 +2215,75 @@ class Converter < ::Prawn::Document
1925
2215
  toc_page_numbers
1926
2216
  end
1927
2217
 
1928
- def layout_toc_level sections, num_levels, line_metrics, dot_width, num_front_matter_pages = 0
1929
- toc_dot_color = @theme.toc_dot_leader_font_color || @theme.toc_font_color || @font_color
2218
+ def layout_toc_level sections, num_levels, line_metrics, dot_leader, num_front_matter_pages = 0
2219
+ # NOTE font options aren't always reliable, so store size separately
2220
+ toc_font_info = theme_font :toc do
2221
+ { font: font, size: @font_size }
2222
+ end
1930
2223
  sections.each do |sect|
1931
2224
  theme_font :toc, level: (sect.level + 1) do
1932
- sect_title = @text_transform ? (transform_text sect.numbered_title, @text_transform) : sect.numbered_title
1933
- # NOTE we do some cursor hacking here so the dots don't affect vertical alignment
1934
- start_page_number = page_number
1935
- start_cursor = cursor
1936
- # NOTE CMYK value gets flattened here, but is restored by formatted text parser
1937
- # FIXME use layout_prose
1938
- typeset_text %(<a anchor="#{sect_anchor = sect.attr 'pdf-anchor'}"><color rgb="#{@font_color}">#{sect_title}</color></a>), line_metrics, inline_format: true
1939
- # we only write the label if this is a dry run
1940
- unless scratch?
2225
+ sect_title = (transform = @text_transform) && transform != 'none' ?
2226
+ (transform_text sect.numbered_title, transform) : sect.numbered_title
2227
+ # NOTE only write section title (excluding dots and page number) if this is a dry run
2228
+ if scratch?
2229
+ # FIXME use layout_prose
2230
+ # NOTE must wrap title in empty anchor element in case links are styled with different font family / size
2231
+ typeset_text %(<a>#{sect_title}</a>), line_metrics, inline_format: true
2232
+ else
2233
+ pgnum_label = ((sect.attr 'pdf-page-start') - num_front_matter_pages).to_s
2234
+ start_page_number = page_number
2235
+ start_cursor = cursor
2236
+ # NOTE use low-level text formatter to add anchor overlay without styling text as link & force color
2237
+ sect_title_format_override = {
2238
+ anchor: (sect_anchor = sect.attr 'pdf-anchor'),
2239
+ color: @font_color,
2240
+ styles: ((@theme[%(toc_h#{sect.level + 1}_text_decoration)] || @theme.toc_text_decoration) == 'underline' ?
2241
+ (font_styles << :underline) : font_styles)
2242
+ }
2243
+ (sect_title_fragments = text_formatter.format sect_title).each do |fragment|
2244
+ fragment.update sect_title_format_override do |key, old_val, new_val|
2245
+ key == :styles ? (old_val.merge new_val) : new_val
2246
+ end
2247
+ end
2248
+ typeset_formatted_text sect_title_fragments, line_metrics
1941
2249
  end_page_number = page_number
1942
2250
  end_cursor = cursor
1943
2251
  # TODO it would be convenient to have a cursor mark / placement utility that took page number into account
1944
2252
  go_to_page start_page_number if start_page_number != end_page_number
1945
2253
  move_cursor_to start_cursor
1946
- sect_pgnum_label = (sect.attr 'pdf-page-start') - num_front_matter_pages
1947
- spacer_width = (width_of NoBreakSpace) * 0.75
1948
- # FIXME this calculation will be wrong if a style is set per level
1949
- num_dots = ((bounds.width - (width_of %(#{sect_title}#{sect_pgnum_label}), inline_format: true) - spacer_width) / dot_width).floor
1950
- num_dots = 0 if num_dots < 0
1951
- # FIXME dots don't line up if width of page numbers differ
1952
- typeset_formatted_text [
1953
- { text: %(#{(@theme.toc_dot_leader_content || DotLeaderDefault) * num_dots}), color: toc_dot_color },
1954
- # FIXME this spacing doesn't always work out; should we use graphics instead?
1955
- { text: NoBreakSpace, size: (@font_size * 0.5) },
1956
- { text: sect_pgnum_label.to_s, anchor: sect_anchor, color: @font_color }], line_metrics, align: :right
1957
- go_to_page end_page_number if start_page_number != end_page_number
2254
+ if dot_leader[:width] > 0 && (dot_leader[:levels].include? sect.level)
2255
+ pgnum_label_font_settings = { color: @font_color, font: font_family, size: @font_size, styles: font_styles }
2256
+ pgnum_label_width = width_of pgnum_label
2257
+ sect_title_width = width_of sect_title, inline_format: true
2258
+ save_font do
2259
+ # NOTE the same font is used for dot leaders throughout toc
2260
+ set_font toc_font_info[:font], toc_font_info[:size]
2261
+ font_style dot_leader[:font_style]
2262
+ num_dots = ((bounds.width - sect_title_width - dot_leader[:spacer_width] - pgnum_label_width) / dot_leader[:width]).floor
2263
+ # FIXME dots don't line up in columns if width of page numbers differ
2264
+ typeset_formatted_text [
2265
+ { text: (dot_leader[:text] * (num_dots < 0 ? 0 : num_dots)), color: dot_leader[:font_color] },
2266
+ dot_leader[:spacer],
2267
+ { text: pgnum_label, anchor: sect_anchor }.merge(pgnum_label_font_settings)
2268
+ ], line_metrics, align: :right
2269
+ end
2270
+ else
2271
+ typeset_formatted_text [{ text: pgnum_label, color: @font_color, anchor: sect_anchor }], line_metrics, align: :right
2272
+ end
2273
+ go_to_page end_page_number if page_number != end_page_number
1958
2274
  move_cursor_to end_cursor
1959
2275
  end
1960
2276
  end
1961
- if sect.level < num_levels
1962
- indent @theme.toc_indent do
1963
- layout_toc_level sect.sections, num_levels, line_metrics, dot_width, num_front_matter_pages
1964
- end
1965
- end
2277
+ indent @theme.toc_indent do
2278
+ layout_toc_level sect.sections, num_levels, line_metrics, dot_leader, num_front_matter_pages
2279
+ end if sect.level < num_levels
1966
2280
  end
1967
2281
  end
1968
2282
 
1969
- # Reduce icon size to fit inside bounds.height. Icons will not render
2283
+ # Reduce icon height to fit inside bounds.height. Icons will not render
1970
2284
  # properly if they are larger than the current bounds.height.
1971
- def fit_icon_size node, max_size = 24
1972
- (min_height = bounds.height.floor) < max_size ? min_height : max_size
2285
+ def fit_icon_to_bounds preferred_size = 24
2286
+ (max_height = bounds.height) < preferred_size ? max_height : preferred_size
1973
2287
  end
1974
2288
 
1975
2289
  def admonition_icon_data key
@@ -1985,37 +2299,76 @@ class Converter < ::Prawn::Document
1985
2299
  # QUESTION should we short-circuit if setting not specified and if so, which setting?
1986
2300
  return unless (periphery == :header && @theme.header_height) || (periphery == :footer && @theme.footer_height)
1987
2301
  skip = opts[:skip] || 1
1988
- start = skip + 1
2302
+ # NOTE find and advance to first non-imported content page to use as model page
2303
+ return unless (content_start_page = state.pages[skip..-1].index {|p| !p.imported_page? })
2304
+ content_start_page += (skip + 1)
1989
2305
  num_pages = page_count - skip
2306
+ prev_page_number = page_number
2307
+ go_to_page content_start_page
1990
2308
 
1991
2309
  # FIXME probably need to treat doctypes differently
1992
- sections = doc.find_by(context: :section) {|sect| sect.level < 3 } || []
1993
-
1994
- # index chapters and sections by the visual page number on which they start
2310
+ is_book = doc.doctype == 'book'
2311
+ header = doc.header? ? doc.header : nil
2312
+ # TODO make this section threshold configurable (perhaps in theme?)
2313
+ sections = doc.find_by(context: :section) {|sect| sect.level < 3 && sect != header } || []
2314
+
2315
+ # FIXME we need a proper model for all this page counting
2316
+ # FIXME we make a big assumption that part & chapter start on new pages
2317
+ # index parts, chapters and sections by the visual page number on which they start
2318
+ part_start_pages = {}
1995
2319
  chapter_start_pages = {}
1996
2320
  section_start_pages = {}
2321
+ trailing_section_start_pages = {}
1997
2322
  sections.each do |sect|
1998
- if sect.chapter?
1999
- chapter_start_pages[(sect.attr 'pdf-page-start').to_i - skip] ||= (sect.numbered_title formal: true)
2323
+ page_num = (sect.attr 'pdf-page-start').to_i - skip
2324
+ if is_book && ((sect_is_part = sect.part?) || sect.chapter?)
2325
+ if sect_is_part
2326
+ part_start_pages[page_num] ||= (sect.numbered_title formal: true)
2327
+ else
2328
+ chapter_start_pages[page_num] ||= (sect.numbered_title formal: true)
2329
+ if sect.sectname == 'appendix' && !part_start_pages.empty?
2330
+ # FIXME need a better way to indicate that part has ended
2331
+ part_start_pages[page_num] = ''
2332
+ end
2333
+ end
2000
2334
  else
2001
- section_start_pages[(sect.attr 'pdf-page-start').to_i - skip] ||= (sect.numbered_title formal: true)
2335
+ sect_title = trailing_section_start_pages[page_num] = sect.numbered_title formal: true
2336
+ section_start_pages[page_num] ||= sect_title
2002
2337
  end
2003
2338
  end
2004
2339
 
2005
- # index chapters and sections by the visual page number on which they appear
2340
+ # index parts, chapters, and sections by the visual page number on which they appear
2341
+ parts_by_page = {}
2006
2342
  chapters_by_page = {}
2007
2343
  sections_by_page = {}
2008
- last_chap = (doc.attr 'preface-title') || 'Preface'
2344
+ # QUESTION should the default part be the doctitle?
2345
+ last_part = nil
2346
+ # QUESTION should we enforce that the preamble is preface?
2347
+ last_chap = is_book ? (doc.attr 'preface-title', 'Preface') : nil
2009
2348
  last_sect = nil
2349
+ sect_search_threshold = 1
2010
2350
  (1..num_pages).each do |num|
2351
+ if (part = part_start_pages[num])
2352
+ last_part = part
2353
+ end
2011
2354
  if (chap = chapter_start_pages[num])
2012
2355
  last_chap = chap
2013
2356
  end
2014
2357
  if (sect = section_start_pages[num])
2015
2358
  last_sect = sect
2016
- elsif chap
2359
+ elsif part || chap
2360
+ sect_search_threshold = num
2017
2361
  last_sect = nil
2362
+ # NOTE we didn't find a section on this page; look back to find last section started
2363
+ elsif last_sect
2364
+ ((sect_search_threshold)..(num - 1)).reverse_each do |prev|
2365
+ if (sect = trailing_section_start_pages[prev])
2366
+ last_sect = sect
2367
+ break
2368
+ end
2369
+ end
2018
2370
  end
2371
+ parts_by_page[num] = last_part
2019
2372
  chapters_by_page[num] = last_chap
2020
2373
  sections_by_page[num] = last_sect
2021
2374
  end
@@ -2026,35 +2379,8 @@ class Converter < ::Prawn::Document
2026
2379
  doc.set_attr 'document-title', doctitle.main
2027
2380
  doc.set_attr 'document-subtitle', doctitle.subtitle
2028
2381
  doc.set_attr 'page-count', num_pages
2029
-
2030
- # TODO move this to a method so it can be reused; cache results
2031
- content_dict = PageSides.inject({}) do |acc, side|
2032
- side_content = {}
2033
- ColumnPositions.each do |position|
2034
- if (val = @theme[%(#{periphery}_#{side}_#{position}_content)])
2035
- # TODO support image URL (using resolve_image_path)
2036
- if (val.include? ':') && val =~ ImageAttributeValueRx &&
2037
- ::File.readable?(path = (ThemeLoader.resolve_theme_asset $1, (doc.attr 'pdf-stylesdir')))
2038
- attrs = (AttributeList.new $2).parse
2039
- width = resolve_explicit_width attrs, bounds.width
2040
- # QUESTION should we lookup and scale intrinsic width if explicit width is not given?
2041
- unless width
2042
- width = [bounds.width, (intrinsic_image_dimensions path)[:width] * 0.75].min
2043
- end
2044
- side_content[position] = { path: path, width: width }
2045
- else
2046
- side_content[position] = val
2047
- end
2048
- end
2049
- end
2050
- # NOTE set fallbacks if not explicitly disabled
2051
- if side_content.empty? && periphery == :footer && @theme[%(footer_#{side}_content)] != 'none'
2052
- side_content = { side == :recto ? :right : :left => '{page-number}' }
2053
- end
2054
-
2055
- acc[side] = side_content
2056
- acc
2057
- end
2382
+ allow_uri_read = doc.attr? 'allow-uri-read'
2383
+ svg_fallback_font = default_svg_font
2058
2384
 
2059
2385
  if periphery == :header
2060
2386
  trim_line_metrics = calc_line_metrics(@theme.header_line_height || @theme.base_line_height)
@@ -2115,26 +2441,29 @@ class Converter < ::Prawn::Document
2115
2441
 
2116
2442
  colspec_dict = PageSides.inject({}) do |acc, side|
2117
2443
  side_trim_content_width = trim_content_width[side]
2118
- if (custom_colspecs = @theme[%(#{periphery}_#{side}_columns)])
2119
- colspecs = %w(<40% =20% >40%)
2120
- (custom_colspecs.tr ',', ' ').split[0..2].each_with_index {|c, idx| colspecs[idx] = c }
2121
- colspecs = { left: colspecs[0], center: colspecs[1], right: colspecs[2] }
2122
- cml_width = 0
2444
+ if (custom_colspecs = @theme[%(#{periphery}_#{side}_columns)] || @theme[%(#{periphery}_columns)])
2445
+ case (colspecs = (custom_colspecs.to_s.tr ',', ' ').split[0..2]).size
2446
+ when 3
2447
+ colspecs = { left: colspecs[0], center: colspecs[1], right: colspecs[2] }
2448
+ when 2
2449
+ colspecs = { left: colspecs[0], center: '0', right: colspecs[1] }
2450
+ when 0, 1
2451
+ colspecs = { left: '0', center: colspecs[0] || '100', right: '0' }
2452
+ end
2453
+ tot_width = 0
2123
2454
  side_colspecs = colspecs.map {|col, spec|
2124
2455
  if (alignment_char = spec.chr).to_i.to_s != alignment_char
2125
2456
  alignment = AlignmentTable[alignment_char] || :left
2126
- pcwidth = spec[1..-1].to_f
2457
+ rel_width = spec[1..-1].to_f
2127
2458
  else
2128
2459
  alignment = :left
2129
- pcwidth = spec.to_f
2460
+ rel_width = spec.to_f
2130
2461
  end
2131
- # QUESTION should we allow the columns to overlap (capping width at 100%)?
2132
- if (w = side_trim_content_width * (pcwidth / 100.0)) + cml_width > side_trim_content_width
2133
- w = side_trim_content_width - cml_width
2134
- end
2135
- cml_width += w
2136
- [col, { align: alignment, width: w, x: 0 }]
2462
+ tot_width += rel_width
2463
+ [col, { align: alignment, width: rel_width, x: 0 }]
2137
2464
  }.to_h
2465
+ # QUESTION should we allow the columns to overlap (capping width at 100%)?
2466
+ side_colspecs.each {|_, colspec| colspec[:width] = (colspec[:width] / tot_width) * side_trim_content_width }
2138
2467
  side_colspecs[:right][:x] = (side_colspecs[:center][:x] = side_colspecs[:left][:width]) + side_colspecs[:center][:width]
2139
2468
  acc[side] = side_colspecs
2140
2469
  else
@@ -2147,11 +2476,43 @@ class Converter < ::Prawn::Document
2147
2476
  acc
2148
2477
  end
2149
2478
 
2479
+ # TODO move this to a method so it can be reused; cache results
2480
+ content_dict = PageSides.inject({}) do |acc, side|
2481
+ side_content = {}
2482
+ ColumnPositions.each do |position|
2483
+ unless (val = @theme[%(#{periphery}_#{side}_#{position}_content)]).nil_or_empty?
2484
+ # TODO support image URL (using resolve_image_path)
2485
+ if (val.include? ':') && val =~ ImageAttributeValueRx &&
2486
+ ::File.readable?(path = (ThemeLoader.resolve_theme_asset $1, (doc.attr 'pdf-stylesdir')))
2487
+ attrs = (AttributeList.new $2).parse
2488
+ col_width = colspec_dict[side][position][:width]
2489
+ if (fit = attrs['fit']) == 'contain'
2490
+ width = col_width
2491
+ else
2492
+ unless (width = resolve_explicit_width attrs, col_width)
2493
+ # QUESTION should we lookup and scale intrinsic width if explicit width is not given?
2494
+ # NOTE failure message will be reported later when image is rendered
2495
+ width = (to_pt intrinsic_image_dimensions(path)[:width], :px) rescue 0
2496
+ end
2497
+ width = col_width if fit == 'scale-down' && width > col_width
2498
+ end
2499
+ side_content[position] = { path: path, width: width, fit: !!fit }
2500
+ else
2501
+ side_content[position] = val
2502
+ end
2503
+ end
2504
+ end
2505
+ # NOTE set fallbacks if not explicitly disabled
2506
+ if side_content.empty? && periphery == :footer && @theme[%(footer_#{side}_content)] != 'none'
2507
+ side_content = { side == :recto ? :right : :left => '{page-number}' }
2508
+ end
2509
+
2510
+ acc[side] = side_content
2511
+ acc
2512
+ end
2513
+
2150
2514
  stamps = {}
2151
2515
  if trim_bg_color || trim_border_color
2152
- # NOTE switch to first content page so stamp will get created properly (can't create on imported page)
2153
- prev_page_number = page_number
2154
- go_to_page start
2155
2516
  PageSides.each do |side|
2156
2517
  create_stamp trim_stamp_name[side] do
2157
2518
  canvas do
@@ -2175,12 +2536,11 @@ class Converter < ::Prawn::Document
2175
2536
  end
2176
2537
  end
2177
2538
  stamps[periphery] = true
2178
- go_to_page prev_page_number
2179
2539
  end
2180
2540
 
2181
2541
  pagenums_enabled = doc.attr? 'pagenums'
2182
2542
  attribute_missing_doc = doc.attr 'attribute-missing'
2183
- repeat (start..page_count), dynamic: true do
2543
+ repeat (content_start_page..page_count), dynamic: true do
2184
2544
  # NOTE don't write on pages which are imported / inserts (otherwise we can get a corrupt PDF)
2185
2545
  next if page.imported_page?
2186
2546
  pgnum_label = page_number - skip
@@ -2191,8 +2551,9 @@ class Converter < ::Prawn::Document
2191
2551
  colspec_by_position = colspec_dict[side]
2192
2552
  # TODO populate chapter-number
2193
2553
  # TODO populate numbered and unnumbered chapter and section titles
2194
- # FIXME leave page-number attribute unset once we filter lines with unresolved attributes (see below)
2195
- doc.set_attr 'page-number', (pagenums_enabled ? pgnum_label : '')
2554
+ doc.set_attr 'page-number', pgnum_label.to_s if pagenums_enabled
2555
+ # QUESTION should the fallback value be nil instead of empty string? or should we remove attribute if no value?
2556
+ doc.set_attr 'part-title', (parts_by_page[pgnum_label] || '')
2196
2557
  doc.set_attr 'chapter-title', (chapters_by_page[pgnum_label] || '')
2197
2558
  doc.set_attr 'section-title', (sections_by_page[pgnum_label] || '')
2198
2559
  doc.set_attr 'section-or-chapter-title', (sections_by_page[pgnum_label] || chapters_by_page[pgnum_label] || '')
@@ -2214,12 +2575,37 @@ class Converter < ::Prawn::Document
2214
2575
  float do
2215
2576
  # NOTE bounding_box is redundant if trim_v_padding is 0
2216
2577
  bounding_box [colspec[:x], cursor - trim_padding[0]], width: colspec[:width], height: (bounds.height - trim_v_padding) do
2217
- #image content[:path], vposition: trim_img_valign, position: colspec[:align], width: content[:width]
2218
- # NOTE use :fit to prevent image from overflowing page (at the cost of scaling it)
2219
- image content[:path], vposition: trim_img_valign, position: colspec[:align], fit: [content[:width], bounds.height]
2578
+ begin
2579
+ if (img_path = content[:path]).downcase.end_with? '.svg'
2580
+ svg_data = ::IO.read img_path
2581
+ svg_obj = ::Prawn::Svg::Interface.new svg_data, self,
2582
+ position: colspec[:align],
2583
+ vposition: trim_img_valign,
2584
+ width: content[:width],
2585
+ # TODO enforce jail in safe mode
2586
+ enable_file_requests_with_root: (::File.dirname img_path),
2587
+ enable_web_requests: allow_uri_read,
2588
+ fallback_font_name: svg_fallback_font
2589
+ if content[:fit] && svg_obj.document.sizing.output_height > (available_h = bounds.height)
2590
+ svg_obj.resize height: available_h
2591
+ end
2592
+ svg_obj.draw
2593
+ else
2594
+ img_opts = { position: colspec[:align], vposition: trim_img_valign }
2595
+ if content[:fit]
2596
+ img_opts[:fit] = [content[:width], bounds.height]
2597
+ else
2598
+ img_opts[:width] = content[:width]
2599
+ end
2600
+ image img_path, img_opts
2601
+ end
2602
+ rescue => e
2603
+ warn %(asciidoctor: WARNING: could not embed image in running content: #{img_path}; #{e.message})
2604
+ end
2220
2605
  end
2221
2606
  end
2222
2607
  when ::String
2608
+ # NOTE minor optimization
2223
2609
  if content == '{page-number}'
2224
2610
  content = pagenums_enabled ? pgnum_label.to_s : nil
2225
2611
  else
@@ -2248,19 +2634,23 @@ class Converter < ::Prawn::Document
2248
2634
  end
2249
2635
  end
2250
2636
  end
2637
+
2638
+ go_to_page prev_page_number
2251
2639
  nil
2252
2640
  end
2253
2641
 
2254
2642
  def add_outline doc, num_levels = 2, toc_page_nums = [], num_front_matter_pages = 0
2255
2643
  front_matter_counter = RomanNumeral.new 0, :lower
2256
- page_num_labels = {}
2644
+ pagenum_labels = {}
2257
2645
 
2258
- num_front_matter_pages.times do
2259
- page_num_labels[front_matter_counter.to_i] = { P: ::PDF::Core::LiteralString.new(front_matter_counter.next!.to_s) }
2646
+ num_front_matter_pages.times do |n|
2647
+ pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new front_matter_counter.next!.to_s) }
2260
2648
  end
2261
2649
 
2262
- # placeholder for first page of content, in case it's not the destination of an outline entry
2263
- page_num_labels[front_matter_counter.to_i] = { P: ::PDF::Core::LiteralString.new('1') }
2650
+ # add labels for each content page, which is required for reader's page navigator to work correctly
2651
+ (num_front_matter_pages..(page_count - 1)).each_with_index do |n, i|
2652
+ pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new %(#{i + 1})) }
2653
+ end
2264
2654
 
2265
2655
  outline.define do
2266
2656
  # FIXME use sanitize: :plain_text once available
@@ -2270,26 +2660,24 @@ class Converter < ::Prawn::Document
2270
2660
  end
2271
2661
  page title: (doc.attr 'toc-title'), destination: (document.dest_top toc_page_nums.first) if toc_page_nums.first
2272
2662
  # QUESTION any way to get add_outline_level to invoke in the context of the outline?
2273
- document.add_outline_level self, doc.sections, num_levels, page_num_labels, num_front_matter_pages
2663
+ document.add_outline_level self, doc.sections, num_levels
2274
2664
  end
2275
2665
 
2276
- catalog.data[:PageLabels] = state.store.ref Nums: page_num_labels.flatten
2666
+ catalog.data[:PageLabels] = state.store.ref Nums: pagenum_labels.flatten
2277
2667
  catalog.data[:PageMode] = :UseOutlines
2278
2668
  nil
2279
2669
  end
2280
2670
 
2281
- # TODO only nest inside root node if doctype=article
2282
- def add_outline_level outline, sections, num_levels, page_num_labels, num_front_matter_pages
2671
+ # FIXME only nest inside root node if doctype=article
2672
+ def add_outline_level outline, sections, num_levels
2283
2673
  sections.each do |sect|
2284
2674
  sect_title = sanitize sect.numbered_title formal: true
2285
2675
  sect_destination = sect.attr 'pdf-destination'
2286
- sect_pgnum_label = (sect_pgnum = sect.attr 'pdf-page-start') - num_front_matter_pages
2287
- page_num_labels[sect_pgnum - 1] = { P: ::PDF::Core::LiteralString.new(sect_pgnum_label.to_s) }
2288
2676
  if (subsections = sect.sections).empty? || sect.level == num_levels
2289
2677
  outline.page title: sect_title, destination: sect_destination
2290
2678
  elsif sect.level < num_levels + 1
2291
2679
  outline.section sect_title, { destination: sect_destination } do
2292
- add_outline_level outline, subsections, num_levels, page_num_labels, num_front_matter_pages
2680
+ add_outline_level outline, subsections, num_levels
2293
2681
  end
2294
2682
  end
2295
2683
  end
@@ -2303,7 +2691,7 @@ class Converter < ::Prawn::Document
2303
2691
  else
2304
2692
  pdf_doc.render_file target
2305
2693
  # QUESTION restore attributes first?
2306
- @pdfmarks.generate_file target if @pdfmarks
2694
+ @pdfmark.generate_file target if @pdfmark
2307
2695
  end
2308
2696
  # write scratch document if debug is enabled (or perhaps DEBUG_STEPS env)
2309
2697
  #get_scratch_document.render_file 'scratch.pdf'
@@ -2311,8 +2699,8 @@ class Converter < ::Prawn::Document
2311
2699
  end
2312
2700
 
2313
2701
  def register_fonts font_catalog, scripts = 'latin', fonts_dir
2314
- (font_catalog || {}).each do |key, font_styles|
2315
- register_font key => font_styles.map {|style, path| [style.to_sym, (font_path path, fonts_dir)]}.to_h
2702
+ (font_catalog || {}).each do |key, styles|
2703
+ register_font key => styles.map {|style, path| [style.to_sym, (font_path path, fonts_dir)]}.to_h
2316
2704
  end
2317
2705
 
2318
2706
  # FIXME read kerning setting from theme!
@@ -2338,34 +2726,35 @@ class Converter < ::Prawn::Document
2338
2726
  end
2339
2727
  end
2340
2728
 
2341
- def theme_fill_and_stroke_bounds category
2342
- fill_and_stroke_bounds @theme[%(#{category}_background_color)], @theme[%(#{category}_border_color)],
2343
- line_width: @theme[%(#{category}_border_width)],
2344
- radius: @theme[%(#{category}_border_radius)]
2729
+ def theme_fill_and_stroke_bounds category, opts = {}
2730
+ background_color = opts[:background_color] || @theme[%(#{category}_background_color)]
2731
+ fill_and_stroke_bounds background_color, @theme[%(#{category}_border_color)],
2732
+ line_width: @theme[%(#{category}_border_width)],
2733
+ radius: @theme[%(#{category}_border_radius)]
2345
2734
  end
2346
2735
 
2347
2736
  # Insert a top margin space unless cursor is at the top of the page.
2348
- # Start a new page if y value is greater than remaining space on page.
2349
- def margin_top y
2350
- margin y, :top
2737
+ # Start a new page if n value is greater than remaining space on page.
2738
+ def margin_top n
2739
+ margin n, :top
2351
2740
  end
2352
2741
 
2353
2742
  # Insert a bottom margin space unless cursor is at the top of the page (not likely).
2354
- # Start a new page if y value is greater than remaining space on page.
2355
- def margin_bottom y
2356
- margin y, :bottom
2743
+ # Start a new page if n value is greater than remaining space on page.
2744
+ def margin_bottom n
2745
+ margin n, :bottom
2357
2746
  end
2358
2747
 
2359
2748
  # Insert a margin space at the specified side unless cursor is at the top of the page.
2360
- # Start a new page if y value is greater than remaining space on page.
2361
- def margin y, side
2362
- unless y == 0 || at_page_top?
2363
- if cursor > y
2364
- move_down y
2749
+ # Start a new page if n value is greater than remaining space on page.
2750
+ def margin n, side
2751
+ unless n == 0 || at_page_top?
2752
+ # NOTE use low-level cursor calculation to workaround cursor bug in column_box context
2753
+ if y - reference_bounds.absolute_bottom > n
2754
+ move_down n
2365
2755
  else
2366
- # go to the next page
2367
- # NOTE we don't use `move_down cursor` because we often have to check at_page_top?
2368
- @margin_box.move_past_bottom
2756
+ # set cursor at top of next page
2757
+ reference_bounds.move_past_bottom
2369
2758
  end
2370
2759
  end
2371
2760
  end
@@ -2379,6 +2768,8 @@ class Converter < ::Prawn::Document
2379
2768
  end
2380
2769
 
2381
2770
  def theme_font category, opts = {}
2771
+ result = nil
2772
+ # TODO inheriting from generic category should be an option
2382
2773
  if (level = opts[:level])
2383
2774
  family = @theme[%(#{category}_h#{level}_font_family)] || @theme[%(#{category}_font_family)] || @theme.base_font_family
2384
2775
  size = @theme[%(#{category}_h#{level}_font_size)] || @theme[%(#{category}_font_size)] || @theme.base_font_size
@@ -2400,11 +2791,12 @@ class Converter < ::Prawn::Document
2400
2791
  prev_transform, @text_transform = @text_transform, transform if transform
2401
2792
 
2402
2793
  font family, size: size, style: (style && style.to_sym) do
2403
- yield
2794
+ result = yield
2404
2795
  end
2405
2796
 
2406
2797
  @font_color = prev_color if color
2407
2798
  @text_transform = prev_transform if transform
2799
+ result
2408
2800
  end
2409
2801
 
2410
2802
  # Calculate the font size (down to the minimum font size) that would allow
@@ -2414,24 +2806,26 @@ class Converter < ::Prawn::Document
2414
2806
  # font size adjustment is necessary.
2415
2807
  def theme_font_size_autofit fragments, category
2416
2808
  arranger = arrange_fragments_by_line fragments
2417
- adjusted_font_size = nil
2418
2809
  theme_font category do
2419
2810
  # NOTE finalizing the line here generates fragments & calculates their widths using the current font settings
2420
2811
  # CAUTION it also removes zero-width spaces
2421
2812
  arranger.finalize_line
2422
2813
  actual_width = width_of_fragments arranger.fragments
2423
2814
  unless ::Array === (padding = @theme[%(#{category}_padding)])
2424
- padding = [padding] * 4
2815
+ padding = ::Array.new 4, padding
2425
2816
  end
2426
2817
  available_width = bounds.width - (padding[3] || 0) - (padding[1] || 0)
2427
2818
  if actual_width > available_width
2428
- adjusted_font_size = ((available_width * font_size).to_f / actual_width).with_precision 4
2819
+ adjusted_font_size = ((available_width * font_size).to_f / actual_width).truncate_to_precision 4
2429
2820
  if (min = @theme[%(#{category}_font_size_min)] || @theme.base_font_size_min) && adjusted_font_size < min
2430
- adjusted_font_size = min
2821
+ min
2822
+ else
2823
+ adjusted_font_size
2431
2824
  end
2825
+ else
2826
+ nil
2432
2827
  end
2433
2828
  end
2434
- adjusted_font_size
2435
2829
  end
2436
2830
 
2437
2831
  # Arrange fragments by line in an arranger and return an unfinalized arranger.
@@ -2558,7 +2952,7 @@ class Converter < ::Prawn::Document
2558
2952
  # If value is nil, derive an anchor name from the default_value, if given.
2559
2953
  def derive_anchor_from_id value, default_value = nil
2560
2954
  if value
2561
- value.ascii_only? ? value : %(0x#{::PDF::Core.string_to_hex value})
2955
+ value.ascii_only? ? value : %(0x#{::PDF::Core.string_to_hex value})
2562
2956
  elsif default_value
2563
2957
  %(__anchor-#{default_value})
2564
2958
  end
@@ -2570,18 +2964,16 @@ class Converter < ::Prawn::Document
2570
2964
  # specified, do nothing.
2571
2965
  #
2572
2966
  # If the node is a section, and the current y position is the top of the
2573
- # page, set the position equal to the page height to improve the navigation
2967
+ # page, set the y position equal to the page height to improve the navigation
2968
+ # experience. If the current x position is at or inside the left margin, set
2969
+ # the x position equal to 0 (left edge of page) to improve the navigation
2574
2970
  # experience.
2575
2971
  def add_dest_for_block node, id = nil
2576
2972
  if !scratch? && (id ||= node.id)
2577
- # QUESTION should we set precise x value of destination or just 0?
2578
- dest_x = bounds.absolute_left.round 2
2973
+ dest_x = bounds.absolute_left.truncate_to_precision 4
2974
+ # QUESTION when content is aligned to left margin, should we keep precise x value or just use 0?
2579
2975
  dest_x = 0 if dest_x <= page_margin_left
2580
- dest_y = if at_page_top? && (node.context == :section || node.context == :document)
2581
- page_height
2582
- else
2583
- y
2584
- end
2976
+ dest_y = at_page_top? && (node.context == :section || node.context == :document) ? page_height : y
2585
2977
  # TODO find a way to store only the ref of the destination; look it up when we need it
2586
2978
  node.set_attr 'pdf-destination', (node_dest = (dest_xyz dest_x, dest_y))
2587
2979
  add_dest id, node_dest
@@ -2600,10 +2992,10 @@ class Converter < ::Prawn::Document
2600
2992
 
2601
2993
  # Resolve the system path of the specified image path.
2602
2994
  #
2603
- # Resolve and normalize the absolute system path of the specified image,
2604
- # taking into account the imagesdir attribute. If an image path is not
2605
- # specified, the path is read from the target attribute of the specified
2606
- # document node.
2995
+ # Resolve and normalize the absolute system path of the specified image. If
2996
+ # the image_path argument is not specified, the path is read from the target
2997
+ # attribute of the specified document node. Resolve the path relative to the
2998
+ # imagesdir if the relative_to_imagesdir option is specified (default: true).
2607
2999
  #
2608
3000
  # If the target is a URI and the allow-uri-read attribute is set on the
2609
3001
  # document, read the file contents to a temporary file and return the path to
@@ -2616,8 +3008,20 @@ class Converter < ::Prawn::Document
2616
3008
  imagesdir = relative_to_imagesdir ? (resolve_imagesdir doc) : nil
2617
3009
  image_path ||= node.attr 'target'
2618
3010
  image_format ||= ::Asciidoctor::Image.format image_path, (::Asciidoctor::Image === node ? node : nil)
3011
+ # NOTE currently used for inline images
3012
+ if ::Base64 === image_path
3013
+ tmp_image = ::Tempfile.new ['image-', image_format && %(.#{image_format})]
3014
+ tmp_image.binmode unless image_format == 'svg'
3015
+ begin
3016
+ tmp_image.write(::Base64.decode64 image_path)
3017
+ tmp_image.path.extend TemporaryPath
3018
+ rescue
3019
+ nil
3020
+ ensure
3021
+ tmp_image.close
3022
+ end
2619
3023
  # handle case when image is a URI
2620
- if (node.is_uri? image_path) || (imagesdir && (node.is_uri? imagesdir) &&
3024
+ elsif (node.is_uri? image_path) || (imagesdir && (node.is_uri? imagesdir) &&
2621
3025
  (image_path = (node.normalize_web_path image_path, imagesdir, false)))
2622
3026
  unless doc.attr? 'allow-uri-read'
2623
3027
  warn %(asciidoctor: WARNING: allow-uri-read is not enabled; cannot embed remote image: #{image_path}) unless scratch?
@@ -2631,15 +3035,13 @@ class Converter < ::Prawn::Document
2631
3035
  tmp_image = ::Tempfile.new ['image-', image_format && %(.#{image_format})]
2632
3036
  tmp_image.binmode if (binary = image_format != 'svg')
2633
3037
  begin
2634
- open(image_path, (binary ? 'rb' : 'r')) {|fd| tmp_image.write(fd.read) }
2635
- tmp_image_path = tmp_image.path
2636
- tmp_image_path.extend TemporaryPath
3038
+ open(image_path, (binary ? 'rb' : 'r')) {|fd| tmp_image.write fd.read }
3039
+ tmp_image.path.extend TemporaryPath
2637
3040
  rescue
2638
- tmp_image_path = nil
3041
+ nil
2639
3042
  ensure
2640
3043
  tmp_image.close
2641
3044
  end
2642
- tmp_image_path
2643
3045
  # handle case when image is a local file
2644
3046
  else
2645
3047
  ::File.expand_path(node.normalize_system_path image_path, imagesdir, nil, target_name: 'image')
@@ -2676,6 +3078,22 @@ class Converter < ::Prawn::Document
2676
3078
  end
2677
3079
  end
2678
3080
 
3081
+ # Resolves the explicit width as a PDF pt value if the value is specified in
3082
+ # absolute units, but defers resolving a percentage value until later.
3083
+ #
3084
+ # See resolve_explicit_width method for details about which attributes are considered.
3085
+ def preresolve_explicit_width attrs
3086
+ if attrs.key? 'pdfwidth'
3087
+ ((width = attrs['pdfwidth']).end_with? '%') ? width : (str_to_pt width)
3088
+ elsif attrs.key? 'scaledwidth'
3089
+ # NOTE the parser automatically appends % if value is unitless
3090
+ ((width = attrs['scaledwidth']).end_with? '%') ? width : (str_to_pt width)
3091
+ elsif attrs.key? 'width'
3092
+ # QUESTION should we honor percentage width value?
3093
+ to_pt attrs['width'].to_f, :px
3094
+ end
3095
+ end
3096
+
2679
3097
  # Resolves the explicit width as a PDF pt value, if specified.
2680
3098
  #
2681
3099
  # Resolves the explicit width, first considering the pdfwidth attribute, then
@@ -2686,6 +3104,7 @@ class Converter < ::Prawn::Document
2686
3104
  #--
2687
3105
  # QUESTION should we enforce positive result?
2688
3106
  def resolve_explicit_width attrs, max_width = bounds.width, opts = {}
3107
+ # QUESTION should we restrict width to max_width for pdfwidth?
2689
3108
  if attrs.key? 'pdfwidth'
2690
3109
  if (width = attrs['pdfwidth']).end_with? '%'
2691
3110
  (width.to_f / 100) * max_width
@@ -2695,7 +3114,12 @@ class Converter < ::Prawn::Document
2695
3114
  str_to_pt width
2696
3115
  end
2697
3116
  elsif attrs.key? 'scaledwidth'
2698
- (attrs['scaledwidth'].to_f / 100) * max_width
3117
+ # NOTE the parser automatically appends % if value is unitless
3118
+ if (width = attrs['scaledwidth']).end_with? '%'
3119
+ (width.to_f / 100) * max_width
3120
+ else
3121
+ str_to_pt width
3122
+ end
2699
3123
  elsif opts[:use_fallback] && (width = @theme.image_width)
2700
3124
  if width.end_with? '%'
2701
3125
  (width.to_f / 100) * max_width
@@ -2706,8 +3130,7 @@ class Converter < ::Prawn::Document
2706
3130
  end
2707
3131
  elsif attrs.key? 'width'
2708
3132
  # QUESTION should we honor percentage width value?
2709
- # NOTE scale width down 75% to convert px to pt; restrict width to max width
2710
- [max_width, attrs['width'].to_f * 0.75].min
3133
+ [max_width, (to_pt attrs['width'].to_f, :px)].min
2711
3134
  end
2712
3135
  end
2713
3136
 
@@ -2721,6 +3144,15 @@ class Converter < ::Prawn::Document
2721
3144
  warn %(asciidoctor: WARNING: could not delete temporary image: #{path}; #{e.message})
2722
3145
  end
2723
3146
 
3147
+ # NOTE assume URL is escaped (i.e., contains character references such as &amp;)
3148
+ def breakable_uri uri
3149
+ scheme, address = uri.split UriSchemeBoundaryRx, 2
3150
+ address, scheme = scheme, address unless address
3151
+ address = address.gsub UriBreakCharsRx, UriBreakCharRepl
3152
+ address.slice!(-2) if address[-2] == ZeroWidthSpace
3153
+ %(#{scheme}#{address})
3154
+ end
3155
+
2724
3156
  # QUESTION move to prawn/extensions.rb?
2725
3157
  def init_scratch_prototype
2726
3158
  # IMPORTANT don't set font before using Marshal, it causes serialization to fail