asciidoctor-pdf 2.0.0.beta.2 → 2.0.0.rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -156,30 +156,6 @@ module Asciidoctor
156
156
  ::Asciidoctor::Inline === node ? result : self
157
157
  end
158
158
 
159
- def traverse node, opts = {}
160
- # NOTE: need to reconfigure document to use scratch converter in scratch document
161
- if self == (prev_converter = node.document.converter)
162
- prev_converter = nil
163
- else
164
- node.document.instance_variable_set :@converter, self
165
- end
166
- if node.blocks?
167
- node.content
168
- elsif node.content_model != :compound && (string = node.content)
169
- prose_opts = opts.merge hyphenate: true, margin_bottom: 0
170
- if (bottom_gutter = @bottom_gutters[-1][node])
171
- prose_opts[:bottom_gutter] = bottom_gutter
172
- end
173
- ink_prose string, prose_opts
174
- end
175
- ensure
176
- node.document.instance_variable_set :@converter, prev_converter if prev_converter
177
- end
178
-
179
- def log severity, message = nil, &block
180
- logger.send severity, message, &block unless scratch?
181
- end
182
-
183
159
  def convert_document doc
184
160
  doc.promote_preface_block
185
161
  init_pdf doc
@@ -195,15 +171,18 @@ module Asciidoctor
195
171
  # NOTE: a new page will already be started (page_number = 2) if the front cover image is a PDF
196
172
  ink_cover_page doc, :front
197
173
  has_front_cover = page_number > marked_page_number
198
- has_title_page = ink_title_page doc if (title_page_on = doc.doctype == 'book' || (doc.attr? 'title-page'))
199
-
200
- @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress' && page_number == 0
201
-
202
- start_new_page unless page&.empty? # rubocop:disable Lint/SafeNavigationWithEmpty
203
-
204
- # NOTE: the base font must be set before any content is written to the main or scratch document
205
- # this method is called inside ink_title_page if the title page is active
206
- font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style unless has_title_page
174
+ if (has_title_page = (title_page_on = doc.doctype == 'book' || (doc.attr? 'title-page')) && (start_title_page doc))
175
+ # NOTE: the base font must be set before any content is written to the main or scratch document
176
+ font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style
177
+ ink_title_page doc
178
+ start_new_page
179
+ else
180
+ @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress' && page_number == 0
181
+ start_new_page unless page&.empty? # rubocop:disable Lint/SafeNavigationWithEmpty
182
+ # NOTE: the base font must be set before any content is written to the main or scratch document
183
+ # this method is called inside ink_title_page if the title page is active
184
+ font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style
185
+ end
207
186
 
208
187
  unless title_page_on
209
188
  body_start_page_number = page_number
@@ -217,14 +196,14 @@ module Asciidoctor
217
196
  indent_section do
218
197
  toc_num_levels = (doc.attr 'toclevels', 2).to_i
219
198
  if (insert_toc = (doc.attr? 'toc') && !((toc_placement = doc.attr 'toc-placement') == 'macro' || toc_placement == 'preamble') && doc.sections?)
220
- start_new_page if @ppbook && verso_page?
199
+ start_toc_page doc, toc_placement if title_page_on
221
200
  add_dest_for_block doc, id: 'toc', y: (at_page_top? ? page_height : nil)
222
201
  @toc_extent = allocate_toc doc, toc_num_levels, cursor, title_page_on
223
202
  else
224
203
  @toc_extent = nil
225
204
  end
226
205
 
227
- start_new_page if @ppbook && verso_page? && !(((next_block = doc.blocks[0])&.context == :preamble ? next_block.blocks[0] : next_block)&.option? 'nonfacing')
206
+ start_new_page if @ppbook && verso_page? && !(((next_block = doc.first_child)&.context == :preamble ? next_block.first_child : next_block)&.option? 'nonfacing')
228
207
 
229
208
  if title_page_on
230
209
  zero_page_offset = has_front_cover ? 1 : 0
@@ -383,7 +362,7 @@ module Asciidoctor
383
362
  @ppbook = nil
384
363
  end
385
364
  # QUESTION: should ThemeLoader handle registering fonts instead?
386
- register_fonts theme.font_catalog, (doc.attr 'pdf-fontsdir', 'GEM_FONTS_DIR')
365
+ register_fonts theme.font_catalog, ((doc.attr 'pdf-fontsdir')&.sub '{docdir}', (doc.attr 'docdir')) || 'GEM_FONTS_DIR'
387
366
  default_kerning theme.base_font_kerning != 'none'
388
367
  @fallback_fonts = Array theme.font_fallbacks
389
368
  @allow_uri_read = doc.attr? 'allow-uri-read'
@@ -405,12 +384,13 @@ module Asciidoctor
405
384
  @font_scale = 1
406
385
  @font_color = theme.base_font_color
407
386
  @text_decoration_width = theme.base_text_decoration_width
408
- @base_text_align = (align = doc.attr 'text-align') && (TextAlignmentNames.include? align) ? align : theme.base_text_align
387
+ @base_text_align = (text_align = doc.attr 'text-align') && (TextAlignmentNames.include? text_align) ? text_align : theme.base_text_align
409
388
  @base_line_height = theme.base_line_height
410
389
  @cjk_line_breaks = doc.attr? 'scripts', 'cjk'
411
- if (hyphen_lang = doc.attr 'hyphens') &&
390
+ if (hyphen_lang = (doc.attr 'hyphens') ||
391
+ (((doc.attribute_locked? 'hyphens') || ((doc.instance_variable_get :@attributes_modified).include? 'hyphens')) ? nil : @theme.base_hyphens)) &&
412
392
  ((defined? ::Text::Hyphen::VERSION) || !(Helpers.require_library 'text/hyphen', 'text-hyphen', :warn).nil?)
413
- hyphen_lang = doc.attr 'lang' if hyphen_lang.empty?
393
+ hyphen_lang = doc.attr 'lang' if !(::String === hyphen_lang) || hyphen_lang.empty?
414
394
  hyphen_lang = 'en_us' if hyphen_lang.nil_or_empty? || hyphen_lang == 'en'
415
395
  hyphen_lang = (hyphen_lang.tr '-', '_').downcase
416
396
  @hyphenator = ::Text::Hyphen.new language: hyphen_lang
@@ -434,75 +414,10 @@ module Asciidoctor
434
414
  if (@optimize = doc.attr 'optimize')
435
415
  @optimize = nil unless (defined? ::Asciidoctor::PDF::Optimizer) || !(Helpers.require_library OptimizerRequirePath, 'rghost', :warn).nil? # rubocop:disable Style/SoleNestedConditional
436
416
  end
437
- allocate_prototype
417
+ allocate_scratch_prototype
438
418
  self
439
419
  end
440
420
 
441
- def load_theme doc
442
- @theme ||= begin # rubocop:disable Naming/MemoizedInstanceVariableName
443
- if (theme = doc.options[:pdf_theme])
444
- theme = theme.dup
445
- @themesdir = ::File.expand_path theme.__dir__ || (doc.attr 'pdf-themesdir') || ::Dir.pwd
446
- elsif (theme_name = doc.attr 'pdf-theme')
447
- theme = ThemeLoader.load_theme theme_name, (user_themesdir = doc.attr 'pdf-themesdir')
448
- @themesdir = theme.__dir__
449
- else
450
- @themesdir = (theme = ThemeLoader.load_theme).__dir__
451
- end
452
- prepare_theme theme
453
- rescue
454
- if user_themesdir
455
- message = %(could not locate or load the pdf theme `#{theme_name}' in #{user_themesdir})
456
- else
457
- message = %(could not locate or load the built-in pdf theme `#{theme_name}')
458
- end
459
- message += %( because of #{$!.class} #{$!.message})
460
- log :error, (message.sub %r/$/, '; reverting to default theme')
461
- @themesdir = (theme = ThemeLoader.load_theme).__dir__
462
- prepare_theme theme
463
- end
464
- end
465
-
466
- def prepare_theme theme
467
- theme.base_font_color ||= '000000'
468
- theme.base_font_size ||= 12
469
- theme.base_font_style = theme.base_font_style&.to_sym || :normal
470
- theme.page_numbering_start_at ||= 'body'
471
- theme.running_content_start_at ||= 'body'
472
- theme.heading_margin_page_top ||= 0
473
- theme.heading_margin_top ||= 0
474
- theme.heading_margin_bottom ||= 0
475
- theme.prose_text_indent ||= 0
476
- theme.prose_text_indent_inner ||= 0
477
- theme.prose_margin_bottom ||= 0
478
- theme.block_margin_bottom ||= 0
479
- theme.list_indent ||= 0
480
- theme.list_item_spacing ||= 0
481
- theme.description_list_term_spacing ||= 0
482
- theme.description_list_description_indent ||= 0
483
- theme.table_border_color ||= (theme.base_border_color || '000000')
484
- theme.table_border_width ||= 0.5
485
- theme.thematic_break_border_color ||= (theme.base_border_color || '000000')
486
- theme.image_border_width ||= 0
487
- theme.code_linenum_font_color ||= '999999'
488
- theme.callout_list_margin_top_after_code ||= 0
489
- theme.role_unresolved_font_color ||= 'FF0000'
490
- theme.footnotes_item_spacing ||= 0
491
- theme.index_columns ||= 2
492
- theme.index_column_gap ||= theme.base_font_size
493
- theme.kbd_separator ||= '+'
494
- theme.title_page_authors_delimiter ||= ', '
495
- theme.title_page_revision_delimiter ||= ', '
496
- theme.toc_indent ||= 0
497
- theme.toc_hanging_indent ||= 0
498
- if ::Array === (quotes = theme.quotes)
499
- TypographicQuotes.each_with_index {|char, idx| quotes[idx] ||= char }
500
- else
501
- theme.quotes = TypographicQuotes
502
- end
503
- theme
504
- end
505
-
506
421
  def build_pdf_options doc, theme
507
422
  case (page_margin = (doc.attr 'pdf-page-margin') || theme.page_margin)
508
423
  when ::Array
@@ -620,35 +535,86 @@ module Asciidoctor
620
535
  info
621
536
  end
622
537
 
623
- # NOTE: init_page is called within a float context; this will suppress prawn-svg messing with the cursor
624
- # NOTE: init_page is not called for imported pages, front and back cover pages, and other image pages
625
- def init_page *_args
626
- next_page_side = page_side nil, @folio_placement[:inverted]
627
- if @media == 'prepress' && (next_page_margin = @page_margin_by_side[page_number == 1 ? :cover : next_page_side]) != page_margin
628
- set_page_margin next_page_margin
538
+ def load_theme doc
539
+ @theme ||= begin # rubocop:disable Naming/MemoizedInstanceVariableName
540
+ if (theme = doc.options[:pdf_theme])
541
+ theme = theme.dup
542
+ @themesdir = ::File.expand_path theme.__dir__ ||
543
+ (user_themesdir = ((doc.attr 'pdf-themesdir')&.sub '{docdir}', (doc.attr 'docdir')) || ::Dir.pwd)
544
+ elsif (theme_name = doc.attr 'pdf-theme')
545
+ theme = ThemeLoader.load_theme theme_name, (user_themesdir = (doc.attr 'pdf-themesdir')&.sub '{docdir}', (doc.attr 'docdir'))
546
+ @themesdir = theme.__dir__
547
+ else
548
+ @themesdir = (theme = ThemeLoader.load_theme).__dir__
549
+ end
550
+ prepare_theme theme
551
+ rescue
552
+ if user_themesdir
553
+ message = %(could not locate or load the pdf theme `#{theme_name}' in #{user_themesdir})
554
+ else
555
+ message = %(could not locate or load the built-in pdf theme `#{theme_name}')
556
+ end
557
+ message += %( because of #{$!.class} #{$!.message})
558
+ log :error, (message.sub %r/$/, '; reverting to default theme')
559
+ @themesdir = (theme = ThemeLoader.load_theme).__dir__
560
+ prepare_theme theme
629
561
  end
630
- unless @page_bg_color == 'FFFFFF'
631
- tare = true
632
- fill_absolute_bounds @page_bg_color
562
+ end
563
+
564
+ def prepare_theme theme
565
+ theme.base_font_color ||= '000000'
566
+ theme.base_font_size ||= 12
567
+ theme.base_font_style = theme.base_font_style&.to_sym || :normal
568
+ theme.page_numbering_start_at ||= 'body'
569
+ theme.running_content_start_at ||= 'body'
570
+ theme.heading_margin_page_top ||= 0
571
+ theme.heading_margin_top ||= 0
572
+ theme.heading_margin_bottom ||= 0
573
+ theme.prose_text_indent ||= 0
574
+ theme.prose_text_indent_inner ||= 0
575
+ theme.prose_margin_bottom ||= 0
576
+ theme.block_margin_bottom ||= 0
577
+ theme.list_indent ||= 0
578
+ theme.list_item_spacing ||= 0
579
+ theme.description_list_term_spacing ||= 0
580
+ theme.description_list_description_indent ||= 0
581
+ theme.table_border_color ||= (theme.base_border_color || '000000')
582
+ theme.table_border_width ||= 0.5
583
+ theme.thematic_break_border_color ||= (theme.base_border_color || '000000')
584
+ theme.image_border_width ||= 0
585
+ theme.code_linenum_font_color ||= '999999'
586
+ theme.callout_list_margin_top_after_code ||= 0
587
+ theme.role_unresolved_font_color ||= 'FF0000'
588
+ theme.footnotes_item_spacing ||= 0
589
+ theme.index_columns ||= 2
590
+ theme.index_column_gap ||= theme.base_font_size
591
+ theme.kbd_separator ||= '+'
592
+ theme.title_page_authors_delimiter ||= ', '
593
+ theme.title_page_revision_delimiter ||= ', '
594
+ theme.toc_indent ||= 0
595
+ theme.toc_hanging_indent ||= 0
596
+ if ::Array === (quotes = theme.quotes)
597
+ TypographicQuotes.each_with_index {|char, idx| quotes[idx] ||= char }
598
+ else
599
+ theme.quotes = TypographicQuotes
633
600
  end
634
- if (bg_image_path, bg_image_opts = @page_bg_image[next_page_side])
635
- tare = true
636
- begin
637
- if bg_image_opts[:format] == 'pdf'
638
- # NOTE: pages that use PDF for the background do not support a background color or running content
639
- # IMPORTANT: the background PDF must have the same dimensions as the current PDF
640
- import_page bg_image_path, (bg_image_opts.merge replace: true, advance: false, advance_if_missing: false)
641
- else
642
- canvas { image bg_image_path, ({ position: :center, vposition: :center }.merge bg_image_opts) }
643
- end
644
- rescue
645
- facing_page_side = (PageSides - [next_page_side])[0]
646
- @page_bg_image[facing_page_side] = nil if @page_bg_image[facing_page_side] == @page_bg_image[next_page_side]
647
- @page_bg_image[next_page_side] = nil
648
- log :warn, %(could not embed page background image: #{bg_image_path}; #{$!.message})
649
- end
601
+ theme
602
+ end
603
+
604
+ def indent_section
605
+ if (values = @section_indent)
606
+ indent(values[0], values[1]) { yield }
607
+ else
608
+ yield
609
+ end
610
+ end
611
+
612
+ def outdent_section enabled = true
613
+ if enabled && (values = @section_indent)
614
+ indent(-values[0], -values[1]) { yield }
615
+ else
616
+ yield
650
617
  end
651
- page.tare_content_stream if tare
652
618
  end
653
619
 
654
620
  def convert_section sect, _opts = {}
@@ -657,7 +623,7 @@ module Asciidoctor
657
623
  sect.context = :open
658
624
  return convert_abstract sect
659
625
  elsif (index_section = sectname == 'index') && @index.empty?
660
- sect.parent.blocks.delete sect
626
+ sect.remove
661
627
  return
662
628
  end
663
629
 
@@ -668,10 +634,10 @@ module Asciidoctor
668
634
  title = %(#{title}\n<em class="subtitle">#{subtitle}</em>)
669
635
  end
670
636
  hlevel = sect.level.next
671
- align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
637
+ text_align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
672
638
  chapterlike = !(part = sectname == 'part') && (sectname == 'chapter' || (sect.document.doctype == 'book' && sect.level == 1))
673
639
  hidden = sect.option? 'notitle'
674
- hopts = { align: align, level: hlevel, part: part, chapterlike: chapterlike, outdent: !(part || chapterlike) }
640
+ hopts = { align: text_align, level: hlevel, part: part, chapterlike: chapterlike, outdent: !(part || chapterlike) }
675
641
  if part
676
642
  unless @theme.heading_part_break_before == 'auto'
677
643
  started_new = true
@@ -684,7 +650,7 @@ module Asciidoctor
684
650
  start_new_chapter sect
685
651
  end
686
652
  end
687
- arrange_heading sect, title, hopts unless hidden || started_new || at_page_top? || !sect.blocks?
653
+ arrange_heading sect, title, hopts unless hidden || started_new || at_page_top? || sect.empty?
688
654
  # QUESTION: should we store pdf-page-start, pdf-anchor & pdf-destination in internal map?
689
655
  sect.set_attr 'pdf-page-start', (start_pgnum = page_number)
690
656
  # QUESTION: should we just assign the section this generated id?
@@ -710,58 +676,14 @@ module Asciidoctor
710
676
  sect.set_attr 'pdf-page-end', page_number
711
677
  end
712
678
 
713
- def indent_section
714
- if (values = @section_indent)
715
- indent(values[0], values[1]) { yield }
716
- else
717
- yield
718
- end
719
- end
720
-
721
- def outdent_section enabled = true
722
- if enabled && (values = @section_indent)
723
- indent(-values[0], -values[1]) { yield }
724
- else
725
- yield
726
- end
727
- end
728
-
729
- # QUESTION: if a footnote ref appears in a separate chapter, should the footnote def be duplicated?
730
- def ink_footnotes node
731
- return if (fns = (doc = node.document).footnotes - @rendered_footnotes).empty?
732
- theme_margin :block, :bottom if node.context == :document || node == node.document.blocks[-1]
733
- theme_margin :footnotes, :top
734
- with_dry_run do |extent|
735
- if (single_page_height = extent&.single_page_height) && (delta = cursor - single_page_height - 0.0001) > 0
736
- move_down delta
737
- end
738
- theme_font :footnotes do
739
- (title = doc.attr 'footnotes-title') && (ink_caption title, category: :footnotes)
740
- item_spacing = @theme.footnotes_item_spacing
741
- index_offset = @rendered_footnotes.length
742
- sect_xreftext = node.context == :section && (node.xreftext node.document.attr 'xrefstyle')
743
- fns.each do |fn|
744
- label = (index = fn.index) - index_offset
745
- if sect_xreftext
746
- fn.singleton_class.send :attr_accessor, :label unless fn.respond_to? :label=
747
- fn.label = %(#{label} - #{sect_xreftext})
748
- end
749
- ink_prose %(<a id="_footnotedef_#{index}">#{DummyText}</a>[<a anchor="_footnoteref_#{index}">#{label}</a>] #{fn.text}), margin_bottom: item_spacing, hyphenate: true
750
- end
751
- @rendered_footnotes += fns if extent
752
- end
753
- end
754
- nil
755
- end
756
-
757
679
  def convert_floating_title node
758
680
  title = node.title
759
681
  hlevel = node.level.next
760
- unless (align = resolve_text_align_from_role node.roles)
761
- align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
682
+ unless (text_align = resolve_text_align_from_role node.roles)
683
+ text_align = (@theme[%(heading_h#{hlevel}_text_align)] || @theme.heading_text_align || @base_text_align).to_sym
762
684
  end
763
- hopts = { align: align, level: hlevel, outdent: (parent = node.parent).context == :section }
764
- arrange_heading node, title, hopts unless at_page_top? || node == parent.blocks[-1]
685
+ hopts = { align: text_align, level: hlevel, outdent: node.parent.context == :section }
686
+ arrange_heading node, title, hopts unless at_page_top? || node.last_child?
765
687
  add_dest_for_block node if node.id
766
688
  # QUESTION: should we decouple styles from section titles?
767
689
  theme_font :heading, level: hlevel do
@@ -769,26 +691,84 @@ module Asciidoctor
769
691
  end
770
692
  end
771
693
 
772
- def convert_abstract node
773
- add_dest_for_block node if node.id
774
- outdent_section do
775
- pad_box @theme.abstract_padding do
776
- theme_font :abstract_title do
777
- ink_prose node.title, align: (@theme.abstract_title_text_align || @base_text_align).to_sym, margin_top: @theme.heading_margin_top, margin_bottom: @theme.heading_margin_bottom, line_height: (@theme.heading_line_height || @theme.base_line_height)
778
- end if node.title?
779
- theme_font :abstract do
780
- prose_opts = { align: (@theme.abstract_text_align || @base_text_align).to_sym, hyphenate: true }
781
- if (text_indent = @theme.prose_text_indent) > 0
782
- prose_opts[:indent_paragraphs] = text_indent
783
- end
784
- # FIXME: allow theme to control more first line options
785
- if (line1_font_style = @theme.abstract_first_line_font_style&.to_sym) && line1_font_style != font_style
786
- case line1_font_style
787
- when :normal
788
- first_line_options = { styles: [] }
789
- when :normal_italic
790
- first_line_options = { styles: [:italic] }
791
- else
694
+ def convert_index_section node
695
+ space_needed_for_category = @theme.description_list_term_spacing + (2 * (height_of_typeset_text 'A'))
696
+ pagenum_sequence_style = node.document.attr 'index-pagenum-sequence-style'
697
+ column_box [0, cursor], columns: @theme.index_columns, width: bounds.width, reflow_margins: true, spacer: @theme.index_column_gap do
698
+ @index.categories.each do |category|
699
+ bounds.move_past_bottom if space_needed_for_category > cursor
700
+ ink_prose category.name,
701
+ align: :left,
702
+ inline_format: false,
703
+ margin_bottom: @theme.description_list_term_spacing,
704
+ style: @theme.description_list_term_font_style&.to_sym
705
+ category.terms.each {|term| convert_index_list_item term, pagenum_sequence_style }
706
+ @theme.prose_margin_bottom > cursor ? bounds.move_past_bottom : (move_down @theme.prose_margin_bottom)
707
+ end
708
+ end
709
+ nil
710
+ end
711
+
712
+ def convert_index_list_item term, pagenum_sequence_style = nil
713
+ text = escape_xml term.name
714
+ unless term.container?
715
+ if @media == 'screen'
716
+ case pagenum_sequence_style
717
+ when 'page'
718
+ pagenums = term.dests.uniq {|dest| dest[:page] }.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
719
+ when 'range'
720
+ first_anchor_per_page = term.dests.each_with_object({}) {|dest, accum| accum[dest[:page]] ||= dest[:anchor] }
721
+ pagenums = (consolidate_ranges first_anchor_per_page.keys).map do |range|
722
+ anchor = first_anchor_per_page[(range.include? '-') ? (range.partition '-')[0] : range]
723
+ %(<a anchor="#{anchor}">#{range}</a>)
724
+ end
725
+ else # term
726
+ pagenums = term.dests.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
727
+ end
728
+ else
729
+ pagenums = consolidate_ranges term.dests.map {|dest| dest[:page] }.uniq
730
+ end
731
+ text = %(#{text}, #{pagenums.join ', '})
732
+ end
733
+ subterm_indent = @theme.description_list_description_indent
734
+ ink_prose text, align: :left, margin: 0, hanging_indent: subterm_indent * 2
735
+ indent subterm_indent do
736
+ term.subterms.each do |subterm|
737
+ convert_index_list_item subterm, pagenum_sequence_style
738
+ end
739
+ end unless term.leaf?
740
+ end
741
+
742
+ def convert_preamble node
743
+ # FIXME: core should not be promoting paragraph to preamble if there are no sections
744
+ if (first_block = node.first_child)&.context == :paragraph && node.document.sections? && !first_block.role?
745
+ first_block.role = 'lead'
746
+ end
747
+ traverse node
748
+ theme_margin :block, :bottom, (next_enclosed_block node)
749
+ convert_toc node, placement: 'preamble'
750
+ end
751
+
752
+ def convert_abstract node
753
+ add_dest_for_block node if node.id
754
+ outdent_section do
755
+ pad_box @theme.abstract_padding do
756
+ theme_font :abstract_title do
757
+ ink_prose node.title, align: (@theme.abstract_title_text_align || @base_text_align).to_sym, margin_top: @theme.heading_margin_top, margin_bottom: @theme.heading_margin_bottom, line_height: (@theme.heading_line_height || @theme.base_line_height)
758
+ end if node.title?
759
+ theme_font :abstract do
760
+ prose_opts = { align: (@theme.abstract_text_align || @base_text_align).to_sym, hyphenate: true }
761
+ if (text_indent = @theme.prose_text_indent) > 0
762
+ prose_opts[:indent_paragraphs] = text_indent
763
+ end
764
+ # FIXME: allow theme to control more first line options
765
+ if (line1_font_style = @theme.abstract_first_line_font_style&.to_sym) && line1_font_style != font_style
766
+ case line1_font_style
767
+ when :normal
768
+ first_line_options = { styles: [] }
769
+ when :normal_italic
770
+ first_line_options = { styles: [:italic] }
771
+ else
792
772
  first_line_options = { styles: [font_style, line1_font_style] }
793
773
  end
794
774
  end
@@ -801,12 +781,12 @@ module Asciidoctor
801
781
  prose_opts[:first_line_options] = first_line_options if first_line_options
802
782
  # FIXME: make this cleaner!!
803
783
  if node.blocks?
804
- last_block = (blocks = node.blocks)[-1]
805
- blocks.each do |child|
784
+ last_block = node.last_child
785
+ node.blocks.each do |child|
806
786
  if child.context == :paragraph
807
787
  child.document.playback_attributes child.attributes
808
788
  prose_opts[:margin_bottom] = 0 if child == last_block
809
- ink_prose child.content, ((align = resolve_text_align_from_role child.roles) ? (prose_opts.merge align: align) : prose_opts.dup)
789
+ ink_prose child.content, ((text_align = resolve_text_align_from_role child.roles) ? (prose_opts.merge align: text_align) : prose_opts.dup)
810
790
  prose_opts.delete :first_line_options
811
791
  prose_opts.delete :margin_bottom
812
792
  else
@@ -815,8 +795,8 @@ module Asciidoctor
815
795
  end
816
796
  end
817
797
  elsif node.content_model != :compound && (string = node.content)
818
- if (align = resolve_text_align_from_role node.roles)
819
- prose_opts[:align] = align
798
+ if (text_align = resolve_text_align_from_role node.roles)
799
+ prose_opts[:align] = text_align
820
800
  end
821
801
  ink_prose string, (prose_opts.merge margin_bottom: 0)
822
802
  end
@@ -827,27 +807,16 @@ module Asciidoctor
827
807
  theme_margin :block, :bottom, (next_enclosed_block node)
828
808
  end
829
809
 
830
- def convert_preamble node
831
- # FIXME: core should not be promoting paragraph to preamble if there are no sections
832
- if node.blocks? && (first_block = node.blocks[0]).context == :paragraph && node.document.sections? && !first_block.role?
833
- first_block.role = 'lead'
834
- end
835
- traverse node
836
- theme_margin :block, :bottom, (next_enclosed_block node)
837
- convert_toc node, placement: 'preamble'
838
- end
839
-
840
810
  def convert_paragraph node
841
811
  add_dest_for_block node if node.id
842
812
 
843
813
  prose_opts = { margin_bottom: 0, hyphenate: true }
844
- if (align = resolve_text_align_from_role (roles = node.roles), query_theme: true, remove_predefined: true)
845
- prose_opts[:align] = align
814
+ if (text_align = resolve_text_align_from_role (roles = node.roles), query_theme: true, remove_predefined: true)
815
+ prose_opts[:align] = text_align
846
816
  end
847
817
  role_keys = roles.map {|role| %(role_#{role}).to_sym } unless roles.empty?
848
818
  if (text_indent = @theme.prose_text_indent) > 0 ||
849
- ((text_indent = @theme.prose_text_indent_inner) > 0 &&
850
- (self_idx = (siblings = node.parent.blocks).index node) > 0 && siblings[self_idx - 1].context == :paragraph)
819
+ ((text_indent = @theme.prose_text_indent_inner) > 0 && node.previous_sibling&.context == :paragraph)
851
820
  prose_opts[:indent_paragraphs] = text_indent
852
821
  end
853
822
  if (bottom_gutter = @bottom_gutters[-1][node])
@@ -877,7 +846,7 @@ module Asciidoctor
877
846
 
878
847
  def convert_admonition node
879
848
  type = node.attr 'name'
880
- label_align = @theme.admonition_label_text_align&.to_sym || :center
849
+ label_text_align = @theme.admonition_label_text_align&.to_sym || :center
881
850
  # TODO: allow vertical_align to be a number
882
851
  if (label_valign = @theme.admonition_label_vertical_align&.to_sym || :middle) == :middle
883
852
  label_valign = :center
@@ -893,14 +862,14 @@ module Asciidoctor
893
862
  icon_size = icon_data[:size] || 24
894
863
  label_width = label_min_width || (icon_size * 1.5)
895
864
  elsif (icon_path = has_icon || !(icon_path = (@theme[%(admonition_icon_#{type})] || {})[:image]) ?
896
- (resolve_icon_image_path node, type) :
865
+ (get_icon_image_path node, type) :
897
866
  (ThemeLoader.resolve_theme_asset (apply_subs_discretely doc, icon_path, subs: [:attributes]), @themesdir)) &&
898
867
  (::File.readable? icon_path)
899
868
  icons = true
900
869
  # TODO: introduce @theme.admonition_image_width? or use size key from admonition_icon_<name>?
901
870
  label_width = label_min_width || 36.0
902
871
  else
903
- log :warn, %(admonition icon image#{has_icon ? '' : ' for ' + type.upcase} not found or not readable: #{icon_path || (resolve_icon_image_path node, type, false)})
872
+ log :warn, %(admonition icon image#{has_icon ? '' : ' for ' + type.upcase} not found or not readable: #{icon_path || (get_icon_image_path node, type, false)})
904
873
  end
905
874
  end
906
875
  unless icons
@@ -927,7 +896,7 @@ module Asciidoctor
927
896
  advance_page unless first_page
928
897
  rule_segment_height = start_cursor = cursor
929
898
  rule_segment_height -= last_page.cursor if last_page
930
- bounding_box [0, start_cursor], width: label_width + lpad[1], height: rule_segment_height do
899
+ bounding_box [bounds.left, start_cursor], width: label_width + lpad[1], height: rule_segment_height do
931
900
  stroke_vertical_rule rule_color, at: bounds.right, line_style: rule_style, line_width: rule_width
932
901
  end
933
902
  end
@@ -935,7 +904,7 @@ module Asciidoctor
935
904
  end
936
905
  float do
937
906
  adjusted_font_size = nil
938
- bounding_box [0, cursor], width: label_width, height: label_height do
907
+ bounding_box [bounds.left, cursor], width: label_width, height: label_height do
939
908
  if icons == 'font'
940
909
  # FIXME: we assume icon is square
941
910
  icon_size = fit_icon_to_bounds icon_size
@@ -948,14 +917,14 @@ module Asciidoctor
948
917
  end
949
918
  icon icon_data[:name],
950
919
  valign: label_valign,
951
- align: label_align,
920
+ align: label_text_align,
952
921
  color: (icon_data[:stroke_color] || font_color),
953
922
  size: icon_size
954
923
  elsif icons
955
924
  if (::Asciidoctor::Image.format icon_path) == 'svg'
956
925
  begin
957
926
  svg_obj = ::Prawn::SVG::Interface.new (::File.read icon_path, mode: 'r:UTF-8'), self,
958
- position: label_align,
927
+ position: label_text_align,
959
928
  vposition: label_valign,
960
929
  width: label_width,
961
930
  height: label_height,
@@ -981,7 +950,7 @@ module Asciidoctor
981
950
  if (icon_height = icon_width * (1 / icon_aspect_ratio)) > label_height
982
951
  icon_width *= label_height / icon_height
983
952
  end
984
- embed_image image_obj, image_info, width: icon_width, position: label_align, vposition: label_valign
953
+ embed_image image_obj, image_info, width: icon_width, position: label_text_align, vposition: label_valign
985
954
  rescue
986
955
  log :warn, %(could not embed admonition icon image: #{icon_path}; #{$!.message})
987
956
  icons = nil
@@ -1012,7 +981,7 @@ module Asciidoctor
1012
981
  end
1013
982
  @text_transform = nil # already applied to label
1014
983
  ink_prose label_text,
1015
- align: label_align,
984
+ align: label_text_align,
1016
985
  valign: label_valign,
1017
986
  line_height: 1,
1018
987
  margin: 0,
@@ -1035,12 +1004,167 @@ module Asciidoctor
1035
1004
  theme_margin :block, :bottom, (next_enclosed_block node)
1036
1005
  end
1037
1006
 
1007
+ # QUESTION: can we avoid arranging fragments multiple times (conums & autofit) by eagerly preparing arranger?
1008
+ def convert_code node
1009
+ extensions = []
1010
+ source_chunks = bg_color_override = font_color_override = adjusted_font_size = nil
1011
+ theme_font :code do
1012
+ # HACK: disable built-in syntax highlighter; must be done before calling node.content!
1013
+ if node.style == 'source' && (highlighter = (syntax_hl = node.document.syntax_highlighter)&.highlight? && syntax_hl.name)
1014
+ case highlighter
1015
+ when 'coderay'
1016
+ Helpers.require_library CodeRayRequirePath, 'coderay' unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
1017
+ when 'pygments'
1018
+ Helpers.require_library PygmentsRequirePath, 'pygments.rb' unless defined? ::Pygments::Ext::BlockStyles
1019
+ when 'rouge'
1020
+ Helpers.require_library RougeRequirePath, 'rouge' unless defined? ::Rouge::Formatters::Prawn
1021
+ else
1022
+ highlighter = nil
1023
+ end
1024
+ prev_subs = (subs = node.subs).dup
1025
+ callouts_enabled = subs.include? :callouts
1026
+ highlight_idx = subs.index :highlight
1027
+ # NOTE: scratch? here only applies if listing block is nested inside another block
1028
+ if !highlighter || scratch?
1029
+ highlighter = nil
1030
+ if highlight_idx
1031
+ # switch the :highlight sub back to :specialcharacters
1032
+ subs[highlight_idx] = :specialcharacters
1033
+ else
1034
+ prev_subs = nil
1035
+ end
1036
+ source_string = guard_indentation node.content
1037
+ elsif highlight_idx
1038
+ # NOTE: the source highlighter logic below handles the highlight and callouts subs
1039
+ subs.replace subs - [:highlight, :callouts]
1040
+ # NOTE: indentation guards will be added by the source highlighter logic
1041
+ source_string = expand_tabs node.content
1042
+ else
1043
+ highlighter = prev_subs = nil
1044
+ source_string = guard_indentation node.content
1045
+ end
1046
+ else
1047
+ highlighter = nil
1048
+ source_string = guard_indentation node.content
1049
+ end
1050
+
1051
+ case highlighter
1052
+ when 'coderay'
1053
+ source_string, conum_mapping = extract_conums source_string if callouts_enabled
1054
+ srclang = node.attr 'language', 'text'
1055
+ begin
1056
+ ::CodeRay::Scanners[(srclang = (srclang.start_with? 'html+') ? (srclang.slice 5, srclang.length).to_sym : srclang.to_sym)]
1057
+ rescue ::ArgumentError
1058
+ srclang = :text
1059
+ end
1060
+ fragments = (::CodeRay.scan source_string, srclang).to_prawn
1061
+ source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1062
+ when 'pygments'
1063
+ unless (style = (node.document.attr 'pygments-style')) && (::Pygments::Ext::BlockStyles.available? style)
1064
+ style = 'pastie'
1065
+ end
1066
+ # QUESTION: allow border color to be set by theme for highlighted block?
1067
+ pg_block_styles = ::Pygments::Ext::BlockStyles.for style
1068
+ bg_color_override = pg_block_styles[:background_color]
1069
+ font_color_override = pg_block_styles[:font_color]
1070
+ if source_string.empty?
1071
+ source_chunks = []
1072
+ else
1073
+ lexer = (::Pygments::Lexer.find_by_alias node.attr 'language', 'text') || (::Pygments::Lexer.find_by_mimetype 'text/plain')
1074
+ lexer_opts = { nowrap: true, noclasses: true, stripnl: false, style: style }
1075
+ lexer_opts[:startinline] = !(node.option? 'mixed') if lexer.name == 'PHP'
1076
+ source_string, conum_mapping = extract_conums source_string if callouts_enabled
1077
+ # NOTE: highlight can return nil if something goes wrong; fallback to encoded source string if this happens
1078
+ result = (lexer.highlight source_string, options: lexer_opts) || (node.apply_subs source_string, [:specialcharacters])
1079
+ if node.attr? 'highlight'
1080
+ if (highlight_lines = node.resolve_lines_to_highlight source_string, (node.attr 'highlight')).empty?
1081
+ highlight_lines = nil
1082
+ else
1083
+ pg_highlight_bg_color = pg_block_styles[:highlight_background_color]
1084
+ highlight_lines = highlight_lines.map {|linenum| [linenum, pg_highlight_bg_color] }.to_h
1085
+ end
1086
+ end
1087
+ if (node.option? 'linenums') || (node.attr? 'linenums')
1088
+ linenums = (node.attr 'start', 1).to_i
1089
+ postprocess = true
1090
+ extensions << FormattedText::SourceWrap
1091
+ elsif conum_mapping || highlight_lines
1092
+ postprocess = true
1093
+ end
1094
+ fragments = text_formatter.format result
1095
+ fragments = restore_conums fragments, conum_mapping, linenums, highlight_lines if postprocess
1096
+ source_chunks = guard_indentation_in_fragments fragments
1097
+ end
1098
+ when 'rouge'
1099
+ formatter = (@rouge_formatter ||= ::Rouge::Formatters::Prawn.new theme: (node.document.attr 'rouge-style'), line_gap: @theme.code_line_gap, highlight_background_color: @theme.code_highlight_background_color)
1100
+ # QUESTION: allow border color to be set by theme for highlighted block?
1101
+ bg_color_override = formatter.background_color
1102
+ if source_string.empty?
1103
+ source_chunks = []
1104
+ else
1105
+ if (node.option? 'linenums') || (node.attr? 'linenums')
1106
+ formatter_opts = { line_numbers: true, start_line: (node.attr 'start', 1).to_i }
1107
+ extensions << FormattedText::SourceWrap
1108
+ else
1109
+ formatter_opts = {}
1110
+ end
1111
+ if (srclang = node.attr 'language')
1112
+ if srclang.include? '?'
1113
+ if (lexer = ::Rouge::Lexer.find_fancy srclang) && lexer.tag == 'php' && !(node.option? 'mixed') && !((lexer_opts = lexer.options).key? 'start_inline')
1114
+ lexer = lexer.class.new lexer_opts.merge 'start_inline' => true
1115
+ end
1116
+ elsif (lexer = ::Rouge::Lexer.find srclang)
1117
+ lexer = lexer.new start_inline: true if lexer.tag == 'php' && !(node.option? 'mixed')
1118
+ end
1119
+ end
1120
+ lexer ||= ::Rouge::Lexers::PlainText
1121
+ source_string, conum_mapping = extract_conums source_string if callouts_enabled
1122
+ if (node.attr? 'highlight') && !(hl_lines = (node.resolve_lines_to_highlight source_string, (node.attr 'highlight'))).empty?
1123
+ formatter_opts[:highlight_lines] = hl_lines.map {|linenum| [linenum, true] }.to_h
1124
+ end
1125
+ fragments = formatter.format (lexer.lex source_string), formatter_opts rescue [text: source_string]
1126
+ source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1127
+ end
1128
+ else
1129
+ # NOTE: only format if we detect a need (callouts or inline formatting)
1130
+ source_chunks = (XMLMarkupRx.match? source_string) ? (text_formatter.format source_string) : [text: source_string]
1131
+ end
1132
+ node.subs.replace prev_subs if prev_subs
1133
+ adjusted_font_size = ((node.option? 'autofit') || (node.document.attr? 'autofit-option')) ? (compute_autofit_font_size source_chunks, :code) : nil
1134
+ end
1135
+
1136
+ caption_below = @theme.code_caption_end&.to_sym == :bottom
1137
+ arrange_block node do |extent|
1138
+ add_dest_for_block node if node.id
1139
+ tare_first_page_content_stream do
1140
+ theme_fill_and_stroke_block :code, extent, background_color: bg_color_override, caption_node: caption_below ? nil : node
1141
+ end
1142
+ pad_box @theme.code_padding, node do
1143
+ theme_font :code do
1144
+ typeset_formatted_text source_chunks, (calc_line_metrics @base_line_height),
1145
+ color: (font_color_override || @theme.code_font_color || @font_color),
1146
+ size: adjusted_font_size,
1147
+ bottom_gutter: @bottom_gutters[-1][node],
1148
+ extensions: extensions.empty? ? nil : extensions
1149
+ end
1150
+ end
1151
+ end
1152
+ # TODO: add protection against the bottom caption being widowed
1153
+ ink_caption node, category: :code, end: :bottom if caption_below
1154
+ theme_margin :block, :bottom, (next_enclosed_block node)
1155
+ end
1156
+
1157
+ alias convert_listing convert_code
1158
+ alias convert_literal convert_code
1159
+ alias convert_listing_or_literal convert_code
1160
+
1038
1161
  def convert_example node
1039
1162
  return convert_open node if node.option? 'collapsible'
1163
+ caption_bottom = @theme.example_caption_end&.to_sym == :bottom
1040
1164
  arrange_block node do |extent|
1041
- add_dest_for_block node if node.id # Q: do we want to put anchor above top margin instead?
1165
+ add_dest_for_block node if node.id
1042
1166
  tare_first_page_content_stream do
1043
- theme_fill_and_stroke_block :example, extent, caption_node: node
1167
+ theme_fill_and_stroke_block :example, extent, caption_node: caption_bottom ? nil : node
1044
1168
  end
1045
1169
  pad_box @theme.example_padding, node do
1046
1170
  theme_font :example do
@@ -1048,6 +1172,8 @@ module Asciidoctor
1048
1172
  end
1049
1173
  end
1050
1174
  end
1175
+ # TODO: add protection against the bottom caption being widowed
1176
+ ink_caption node, category: :example, end: :bottom if caption_bottom
1051
1177
  theme_margin :block, :bottom, (next_enclosed_block node)
1052
1178
  end
1053
1179
 
@@ -1097,7 +1223,7 @@ module Asciidoctor
1097
1223
  advance_page unless first_page
1098
1224
  b_height = start_cursor = cursor
1099
1225
  b_height -= last_page.cursor if last_page
1100
- bounding_box [0, start_cursor], width: bounds.width, height: b_height do
1226
+ bounding_box [bounds.left, start_cursor], width: bounds.width, height: b_height do
1101
1227
  stroke_vertical_rule b_color, line_width: b_left_width, at: b_left_width * 0.5
1102
1228
  end
1103
1229
  end
@@ -1118,7 +1244,7 @@ module Asciidoctor
1118
1244
  end
1119
1245
  end
1120
1246
  if attribution
1121
- margin_bottom @theme.block_margin_bottom
1247
+ theme_margin :block, :bottom
1122
1248
  theme_font %(#{category}_cite) do
1123
1249
  attribution_parts = citetitle ? [attribution, citetitle] : [attribution]
1124
1250
  ink_prose %(#{EmDash} #{attribution_parts.join ', '}), align: :left, normalize: false, margin_bottom: 0
@@ -1152,7 +1278,7 @@ module Asciidoctor
1152
1278
  end
1153
1279
 
1154
1280
  def convert_colist node
1155
- unless at_page_top? || (self_idx = (siblings = node.parent.blocks).index node) == 0 || !([:listing, :literal].include? siblings[self_idx - 1].context)
1281
+ if !at_page_top? && ((prev_context = node.previous_sibling&.context) == :listing || prev_context == :literal)
1156
1282
  margin_top @theme.callout_list_margin_top_after_code
1157
1283
  end
1158
1284
  add_dest_for_block node if node.id
@@ -1160,8 +1286,8 @@ module Asciidoctor
1160
1286
  last_item = node.items[-1]
1161
1287
  item_spacing = @theme.callout_list_item_spacing || @theme.list_item_spacing
1162
1288
  item_opts = { margin_bottom: item_spacing, normalize_line_height: true }
1163
- if (item_align = (resolve_text_align_from_role node.roles) || @theme.list_text_align&.to_sym)
1164
- item_opts[:align] = item_align
1289
+ if (item_text_align = (resolve_text_align_from_role node.roles) || @theme.list_text_align&.to_sym)
1290
+ item_opts[:align] = item_text_align
1165
1291
  end
1166
1292
  theme_font :callout_list do
1167
1293
  line_metrics = theme_font(:conum) { calc_line_metrics @base_line_height }
@@ -1181,7 +1307,7 @@ module Asciidoctor
1181
1307
  theme_font :conum do
1182
1308
  marker_width = rendered_width_of_string %(#{marker = conum_glyph index}x)
1183
1309
  float do
1184
- bounding_box [0, cursor], width: marker_width do
1310
+ bounding_box [bounds.left, cursor], width: marker_width do
1185
1311
  ink_prose marker, align: :center, inline_format: false, margin: 0
1186
1312
  end
1187
1313
  end
@@ -1379,13 +1505,13 @@ module Asciidoctor
1379
1505
  ink_caption node, category: :list, labeled: false if node.title?
1380
1506
 
1381
1507
  opts = {}
1382
- if (align = resolve_text_align_from_role node.roles)
1383
- opts[:align] = align
1508
+ if (text_align = resolve_text_align_from_role node.roles)
1509
+ opts[:align] = text_align
1384
1510
  elsif node.style == 'bibliography'
1385
1511
  opts[:align] = :left
1386
- elsif (align = @theme.list_text_align&.to_sym) # rubocop:disable Lint/DuplicateBranch
1512
+ elsif (text_align = @theme.list_text_align&.to_sym) # rubocop:disable Lint/DuplicateBranch
1387
1513
  # NOTE: theme setting only affects alignment of list text (not nested blocks)
1388
- opts[:align] = align
1514
+ opts[:align] = text_align
1389
1515
  end
1390
1516
 
1391
1517
  line_metrics = calc_line_metrics @base_line_height
@@ -1469,7 +1595,7 @@ module Asciidoctor
1469
1595
  character_spacing_correction = 0.5 if (rendered_width_of_char 'x', character_spacing: -0.5) == marker_gap
1470
1596
  end
1471
1597
  marker_height = height_of_typeset_text marker, line_height: marker_style[:line_height], single_line: true
1472
- start_position = -marker_width + -marker_gap + character_spacing_correction
1598
+ start_position = bounds.left - marker_width - marker_gap + character_spacing_correction
1473
1599
  float do
1474
1600
  advance_page if @media == 'prepress' && cursor < marker_height
1475
1601
  flow_bounding_box position: start_position, width: marker_width do
@@ -1498,30 +1624,18 @@ module Asciidoctor
1498
1624
  traverse_list_item node, list_type, opts
1499
1625
  end
1500
1626
 
1501
- def traverse_list_item node, list_type, opts = {}
1502
- if list_type == :dlist # qanda
1503
- terms, desc = node
1504
- terms.each {|term| ink_prose %(<em>#{term.text}</em>), (opts.merge margin_bottom: @theme.description_list_term_spacing) }
1505
- if desc
1506
- ink_prose desc.text, (opts.merge hyphenate: true) if desc.text?
1507
- traverse desc
1508
- end
1509
- else
1510
- if (primary_text = node.text).nil_or_empty?
1511
- ink_prose DummyText, opts unless node.blocks?
1512
- else
1513
- ink_prose primary_text, (opts.merge hyphenate: true)
1627
+ def convert_image node, opts = {}
1628
+ target, image_format = (node.extend ::Asciidoctor::Image).target_and_format
1629
+
1630
+ unless image_format == 'pdf'
1631
+ if (float_to = node.attr 'float') && ((BlockFloatNames.include? float_to) ? float_to : (float_to = nil))
1632
+ alignment = float_to.to_sym
1633
+ elsif (alignment = node.attr 'align')
1634
+ alignment = (BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left
1635
+ elsif !(alignment = node.roles.reverse.find {|r| BlockAlignmentNames.include? r }&.to_sym)
1636
+ alignment = @theme.image_align&.to_sym || :left
1514
1637
  end
1515
- traverse node
1516
1638
  end
1517
- end
1518
-
1519
- def allocate_space_for_list_item line_metrics
1520
- advance_page if !at_page_top? && cursor < line_metrics.height + line_metrics.leading + line_metrics.padding_top
1521
- end
1522
-
1523
- def convert_image node, opts = {}
1524
- target, image_format = (node.extend ::Asciidoctor::Image).target_and_format
1525
1639
 
1526
1640
  if image_format == 'gif' && !(defined? ::GMagick::Image)
1527
1641
  log :warn, %(GIF image format not supported. Install the prawn-gmagick gem or convert #{target} to PNG.)
@@ -1570,15 +1684,8 @@ module Asciidoctor
1570
1684
  end
1571
1685
  end
1572
1686
 
1573
- return on_image_error :missing, node, target, opts unless image_path
1687
+ return on_image_error :missing, node, target, (opts.merge align: alignment) unless image_path
1574
1688
 
1575
- if (float_to = node.attr 'float') && ((BlockFloatNames.include? float_to) ? float_to : (float_to = nil))
1576
- alignment = float_to.to_sym
1577
- elsif (alignment = node.attr 'align')
1578
- alignment = (BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left
1579
- else
1580
- alignment = (resolve_text_align_from_role node.roles) || @theme.image_align&.to_sym || :left
1581
- end
1582
1689
  # TODO: support cover (aka canvas) image layout using "canvas" (or "cover") role
1583
1690
  width = resolve_explicit_width node.attributes, bounds_width: (available_w = bounds.width), support_vw: true, use_fallback: true, constrain_to_bounds: true
1584
1691
  # TODO: add `to_pt page_width` method to ViewportWidth type
@@ -1635,7 +1742,7 @@ module Asciidoctor
1635
1742
  svg_obj.document.warnings.each do |img_warning|
1636
1743
  log :warn, %(problem encountered in image: #{image_path}; #{img_warning})
1637
1744
  end unless scratch?
1638
- draw_image_border image_cursor, rendered_w, rendered_h, alignment unless node.role? && (node.has_role? 'noborder')
1745
+ draw_image_border image_cursor, rendered_w, rendered_h, alignment unless pinned || (node.role? && (node.has_role? 'noborder'))
1639
1746
  if (link = node.attr 'link')
1640
1747
  add_link_to_image link, { width: rendered_w, height: rendered_h }, position: alignment, y: image_y
1641
1748
  end
@@ -1664,7 +1771,7 @@ module Asciidoctor
1664
1771
  image_cursor = cursor
1665
1772
  # NOTE: specify both width and height to avoid recalculation
1666
1773
  embed_image image_obj, image_info, width: rendered_w, height: rendered_h, position: alignment
1667
- draw_image_border image_cursor, rendered_w, rendered_h, alignment unless node.role? && (node.has_role? 'noborder')
1774
+ draw_image_border image_cursor, rendered_w, rendered_h, alignment unless pinned || (node.role? && (node.has_role? 'noborder'))
1668
1775
  if (link = node.attr 'link')
1669
1776
  add_link_to_image link, { width: rendered_w, height: rendered_h }, position: alignment, y: image_y
1670
1777
  end
@@ -1682,65 +1789,8 @@ module Asciidoctor
1682
1789
  end
1683
1790
  rescue => e
1684
1791
  raise if ::StopIteration === e
1685
- on_image_error :exception, node, target, (opts.merge message: %(could not embed image: #{image_path}; #{e.message}#{::Prawn::Errors::UnsupportedImageType === e && !(defined? ::GMagick::Image) ? '; install prawn-gmagick gem to add support' : ''}))
1686
- end
1687
- end
1688
-
1689
- def supports_float_wrapping? node
1690
- node.context == :paragraph
1691
- end
1692
-
1693
- def init_float_box _node, block_width, block_height, float_to
1694
- gap = ::Array === (gap = @theme.image_float_gap) ? gap.dup : [gap, gap]
1695
- float_w = block_width + (gap[0] ||= 12)
1696
- float_h = block_height + (gap[1] ||= 6)
1697
- box_l = bounds.left + (float_to == 'right' ? 0 : float_w)
1698
- box_t = cursor + block_height
1699
- box_w = bounds.width - float_w
1700
- box_r = box_l + box_w
1701
- box_h = [box_t, float_h].min
1702
- box_b = box_t - box_h
1703
- move_cursor_to box_t
1704
- @float_box = { page: page_number, top: box_t, right: box_r, bottom: box_b, left: box_l, width: box_w, height: box_h, gap: gap }
1705
- end
1706
-
1707
- def draw_image_border top, w, h, alignment
1708
- if (Array @theme.image_border_width).any? {|it| it&.> 0 } && (@theme.image_border_color || @theme.base_border_color)
1709
- if (@theme.image_border_fit || 'content') == 'auto'
1710
- bb_width = bounds.width
1711
- elsif alignment == :center
1712
- bb_x = (bounds.width - w) * 0.5
1713
- elsif alignment == :right
1714
- bb_x = bounds.width - w
1715
- end
1716
- bounding_box [(bb_x || 0), top], width: (bb_width || w), height: h, position: alignment do
1717
- theme_fill_and_stroke_bounds :image
1718
- end
1719
- true
1720
- end
1721
- end
1722
-
1723
- def on_image_error _reason, node, target, opts
1724
- log :warn, opts[:message] if opts.key? :message
1725
- alt_text_vars = { alt: (node.attr 'alt'), target: target }
1726
- alt_text_template = @theme.image_alt_content || '%{link}[%{alt}]%{/link} | <em>%{target}</em>' # rubocop:disable Style/FormatStringToken
1727
- return if alt_text_template.empty?
1728
- if (link = node.attr 'link')
1729
- alt_text_vars[:link] = %(<a href="#{link}">)
1730
- alt_text_vars[:'/link'] = '</a>'
1731
- else
1732
- alt_text_vars[:link] = ''
1733
- alt_text_vars[:'/link'] = ''
1792
+ on_image_error :exception, node, target, (opts.merge align: alignment, message: %(could not embed image: #{image_path}; #{e.message}#{::Prawn::Errors::UnsupportedImageType === e && !(defined? ::GMagick::Image) ? '; install prawn-gmagick gem to add support' : ''}))
1734
1793
  end
1735
- theme_font :image_alt do
1736
- alignment = (alignment = node.attr 'align') ?
1737
- ((BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left) :
1738
- (resolve_text_align_from_role node.roles) || (@theme.image_align&.to_sym || :left)
1739
- ink_prose alt_text_template % alt_text_vars, align: alignment, margin: 0, normalize: false, single_line: true
1740
- end
1741
- ink_caption node, category: :image, end: :bottom if node.title?
1742
- theme_margin :block, :bottom, (next_enclosed_block node) unless opts[:pinned]
1743
- nil
1744
1794
  end
1745
1795
 
1746
1796
  def convert_audio node
@@ -1789,158 +1839,28 @@ module Asciidoctor
1789
1839
  end
1790
1840
  end
1791
1841
 
1792
- # QUESTION: can we avoid arranging fragments multiple times (conums & autofit) by eagerly preparing arranger?
1793
- def convert_code node
1794
- extensions = []
1795
- source_chunks = bg_color_override = font_color_override = adjusted_font_size = nil
1796
- theme_font :code do
1797
- # HACK: disable built-in syntax highlighter; must be done before calling node.content!
1798
- if node.style == 'source' && (highlighter = (syntax_hl = node.document.syntax_highlighter)&.highlight? && syntax_hl.name)
1799
- case highlighter
1800
- when 'coderay'
1801
- Helpers.require_library CodeRayRequirePath, 'coderay' unless defined? ::Asciidoctor::Prawn::CodeRayEncoder
1802
- when 'pygments'
1803
- Helpers.require_library PygmentsRequirePath, 'pygments.rb' unless defined? ::Pygments::Ext::BlockStyles
1804
- when 'rouge'
1805
- Helpers.require_library RougeRequirePath, 'rouge' unless defined? ::Rouge::Formatters::Prawn
1806
- else
1807
- highlighter = nil
1808
- end
1809
- prev_subs = (subs = node.subs).dup
1810
- callouts_enabled = subs.include? :callouts
1811
- highlight_idx = subs.index :highlight
1812
- # NOTE: scratch? here only applies if listing block is nested inside another block
1813
- if !highlighter || scratch?
1814
- highlighter = nil
1815
- if highlight_idx
1816
- # switch the :highlight sub back to :specialcharacters
1817
- subs[highlight_idx] = :specialcharacters
1818
- else
1819
- prev_subs = nil
1820
- end
1821
- source_string = guard_indentation node.content
1822
- elsif highlight_idx
1823
- # NOTE: the source highlighter logic below handles the highlight and callouts subs
1824
- subs.replace subs - [:highlight, :callouts]
1825
- # NOTE: indentation guards will be added by the source highlighter logic
1826
- source_string = expand_tabs node.content
1827
- else
1828
- highlighter = prev_subs = nil
1829
- source_string = guard_indentation node.content
1830
- end
1831
- else
1832
- highlighter = nil
1833
- source_string = guard_indentation node.content
1834
- end
1835
-
1836
- case highlighter
1837
- when 'coderay'
1838
- source_string, conum_mapping = extract_conums source_string if callouts_enabled
1839
- srclang = node.attr 'language', 'text'
1840
- begin
1841
- ::CodeRay::Scanners[(srclang = (srclang.start_with? 'html+') ? (srclang.slice 5, srclang.length).to_sym : srclang.to_sym)]
1842
- rescue ::ArgumentError
1843
- srclang = :text
1844
- end
1845
- fragments = (::CodeRay.scan source_string, srclang).to_prawn
1846
- source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1847
- when 'pygments'
1848
- unless (style = (node.document.attr 'pygments-style')) && (::Pygments::Ext::BlockStyles.available? style)
1849
- style = 'pastie'
1850
- end
1851
- # QUESTION: allow border color to be set by theme for highlighted block?
1852
- pg_block_styles = ::Pygments::Ext::BlockStyles.for style
1853
- bg_color_override = pg_block_styles[:background_color]
1854
- font_color_override = pg_block_styles[:font_color]
1855
- if source_string.empty?
1856
- source_chunks = []
1857
- else
1858
- lexer = (::Pygments::Lexer.find_by_alias node.attr 'language', 'text') || (::Pygments::Lexer.find_by_mimetype 'text/plain')
1859
- lexer_opts = { nowrap: true, noclasses: true, stripnl: false, style: style }
1860
- lexer_opts[:startinline] = !(node.option? 'mixed') if lexer.name == 'PHP'
1861
- source_string, conum_mapping = extract_conums source_string if callouts_enabled
1862
- # NOTE: highlight can return nil if something goes wrong; fallback to encoded source string if this happens
1863
- result = (lexer.highlight source_string, options: lexer_opts) || (node.apply_subs source_string, [:specialcharacters])
1864
- if node.attr? 'highlight'
1865
- if (highlight_lines = node.resolve_lines_to_highlight source_string, (node.attr 'highlight')).empty?
1866
- highlight_lines = nil
1867
- else
1868
- pg_highlight_bg_color = pg_block_styles[:highlight_background_color]
1869
- highlight_lines = highlight_lines.map {|linenum| [linenum, pg_highlight_bg_color] }.to_h
1870
- end
1871
- end
1872
- if (node.option? 'linenums') || (node.attr? 'linenums')
1873
- linenums = (node.attr 'start', 1).to_i
1874
- postprocess = true
1875
- extensions << FormattedText::SourceWrap
1876
- elsif conum_mapping || highlight_lines
1877
- postprocess = true
1878
- end
1879
- fragments = text_formatter.format result
1880
- fragments = restore_conums fragments, conum_mapping, linenums, highlight_lines if postprocess
1881
- source_chunks = guard_indentation_in_fragments fragments
1882
- end
1883
- when 'rouge'
1884
- formatter = (@rouge_formatter ||= ::Rouge::Formatters::Prawn.new theme: (node.document.attr 'rouge-style'), line_gap: @theme.code_line_gap, highlight_background_color: @theme.code_highlight_background_color)
1885
- # QUESTION: allow border color to be set by theme for highlighted block?
1886
- bg_color_override = formatter.background_color
1887
- if source_string.empty?
1888
- source_chunks = []
1889
- else
1890
- if (node.option? 'linenums') || (node.attr? 'linenums')
1891
- formatter_opts = { line_numbers: true, start_line: (node.attr 'start', 1).to_i }
1892
- extensions << FormattedText::SourceWrap
1893
- else
1894
- formatter_opts = {}
1895
- end
1896
- if (srclang = node.attr 'language')
1897
- if srclang.include? '?'
1898
- if (lexer = ::Rouge::Lexer.find_fancy srclang) && lexer.tag == 'php' && !(node.option? 'mixed') && !((lexer_opts = lexer.options).key? 'start_inline')
1899
- lexer = lexer.class.new lexer_opts.merge 'start_inline' => true
1900
- end
1901
- elsif (lexer = ::Rouge::Lexer.find srclang)
1902
- lexer = lexer.new start_inline: true if lexer.tag == 'php' && !(node.option? 'mixed')
1903
- end
1904
- end
1905
- lexer ||= ::Rouge::Lexers::PlainText
1906
- source_string, conum_mapping = extract_conums source_string if callouts_enabled
1907
- if (node.attr? 'highlight') && !(hl_lines = (node.resolve_lines_to_highlight source_string, (node.attr 'highlight'))).empty?
1908
- formatter_opts[:highlight_lines] = hl_lines.map {|linenum| [linenum, true] }.to_h
1909
- end
1910
- fragments = formatter.format (lexer.lex source_string), formatter_opts rescue [text: source_string]
1911
- source_chunks = conum_mapping ? (restore_conums fragments, conum_mapping) : fragments
1912
- end
1913
- else
1914
- # NOTE: only format if we detect a need (callouts or inline formatting)
1915
- source_chunks = (XMLMarkupRx.match? source_string) ? (text_formatter.format source_string) : [text: source_string]
1842
+ # NOTE: to insert sequential page breaks, you must put {nbsp} between page breaks
1843
+ def convert_page_break node
1844
+ if (page_layout = node.attr 'page-layout').nil_or_empty?
1845
+ unless node.role? && (page_layout = (node.roles.map(&:to_sym) & PageLayouts)[-1])
1846
+ page_layout = nil
1916
1847
  end
1917
- node.subs.replace prev_subs if prev_subs
1918
- adjusted_font_size = ((node.option? 'autofit') || (node.document.attr? 'autofit-option')) ? (compute_autofit_font_size source_chunks, :code) : nil
1848
+ elsif !(PageLayouts.include? (page_layout = page_layout.to_sym))
1849
+ page_layout = nil
1919
1850
  end
1920
1851
 
1921
- arrange_block node do |extent|
1922
- add_dest_for_block node if node.id
1923
- tare_first_page_content_stream do
1924
- theme_fill_and_stroke_block :code, extent, background_color: bg_color_override, caption_node: node
1925
- end
1926
- pad_box @theme.code_padding, node do
1927
- theme_font :code do
1928
- typeset_formatted_text source_chunks, (calc_line_metrics @base_line_height),
1929
- color: (font_color_override || @theme.code_font_color || @font_color),
1930
- size: adjusted_font_size,
1931
- bottom_gutter: @bottom_gutters[-1][node],
1932
- extensions: extensions.empty? ? nil : extensions
1933
- end
1852
+ if at_page_top?
1853
+ if page_layout && page_layout != page.layout && page.empty?
1854
+ delete_current_page
1855
+ advance_page layout: page_layout
1934
1856
  end
1857
+ elsif page_layout
1858
+ advance_page layout: page_layout
1859
+ else
1860
+ advance_page
1935
1861
  end
1936
-
1937
- theme_margin :block, :bottom, (next_enclosed_block node)
1938
1862
  end
1939
1863
 
1940
- alias convert_listing convert_code
1941
- alias convert_literal convert_code
1942
- alias convert_listing_or_literal convert_code
1943
-
1944
1864
  def convert_pass node
1945
1865
  theme_font :code do
1946
1866
  typeset_formatted_text [text: (guard_indentation node.content), color: @theme.base_font_color], (calc_line_metrics @base_line_height)
@@ -1963,100 +1883,14 @@ module Asciidoctor
1963
1883
  theme_margin :block, :bottom, (next_enclosed_block node)
1964
1884
  end
1965
1885
 
1966
- # Extract callout marks from string, indexed by 0-based line number
1967
- # Return an Array with the processed string as the first argument
1968
- # and the mapping of lines to conums as the second.
1969
- def extract_conums string
1970
- conum_mapping = {}
1971
- auto_num = 0
1972
- string = (string.split LF).map.with_index do |line, line_num|
1973
- # FIXME: we get extra spaces before numbers if more than one on a line
1974
- if line.include? '<'
1975
- line = line.gsub CalloutExtractRx do
1976
- # honor the escape
1977
- if $1 == ?\\
1978
- $&.sub $1, ''
1979
- else
1980
- (conum_mapping[line_num] ||= []) << ($3 == '.' ? (auto_num += 1) : $3.to_i)
1981
- ''
1982
- end
1983
- end
1984
- # NOTE: use first position to store space that precedes conums
1985
- if (conum_mapping.key? line_num) && (line.end_with? ' ')
1986
- trimmed_line = line.rstrip
1987
- conum_mapping[line_num].unshift line.slice trimmed_line.length, line.length
1988
- line = trimmed_line
1989
- end
1990
- end
1991
- line
1992
- end.join LF
1993
- conum_mapping = nil if conum_mapping.empty?
1994
- [string, conum_mapping]
1995
- end
1996
-
1997
- # Restore the conums into the Array of formatted text fragments
1998
- #--
1999
- # QUESTION: can this be done more efficiently?
2000
- # QUESTION: can we reuse arrange_fragments_by_line?
2001
- def restore_conums fragments, conum_mapping, linenums = nil, highlight_lines = nil
2002
- lines = []
2003
- line_num = 0
2004
- # reorganize the fragments into an array of lines
2005
- fragments.each do |fragment|
2006
- line = (lines[line_num] ||= [])
2007
- if (text = fragment[:text]) == LF
2008
- lines[line_num += 1] ||= []
2009
- elsif text.include? LF
2010
- (text.split LF, -1).each_with_index do |line_in_fragment, idx|
2011
- line = (lines[line_num += 1] ||= []) unless idx == 0
2012
- line << (fragment.merge text: line_in_fragment) unless line_in_fragment.empty?
2013
- end
2014
- else
2015
- line << fragment
2016
- end
2017
- end
2018
- conum_font_color = @theme.conum_font_color
2019
- if (conum_font_name = @theme.conum_font_family) == font_name
2020
- conum_font_name = nil
2021
- end
2022
- last_line_num = lines.size - 1
2023
- if linenums
2024
- pad_size = (last_line_num + 1).to_s.length
2025
- linenum_color = @theme.code_linenum_font_color
2026
- end
2027
- # append conums to appropriate lines, then flatten to an array of fragments
2028
- lines.flat_map.with_index do |line, cur_line_num|
2029
- last_line = cur_line_num == last_line_num
2030
- visible_line_num = cur_line_num + (linenums || 1)
2031
- if highlight_lines && (highlight_bg_color = highlight_lines[visible_line_num])
2032
- line.unshift text: DummyText, background_color: highlight_bg_color, highlight: true, inline_block: true, extend: true, width: 0, callback: [FormattedText::TextBackgroundAndBorderRenderer]
2033
- end
2034
- line.unshift text: %(#{visible_line_num.to_s.rjust pad_size} ), linenum: visible_line_num, color: linenum_color if linenums
2035
- if conum_mapping && (conums = conum_mapping.delete cur_line_num)
2036
- line << { text: conums.shift } if ::String === conums[0]
2037
- conum_text = conums.map {|num| conum_glyph num }.join ' '
2038
- conum_fragment = { text: conum_text }
2039
- conum_fragment[:color] = conum_font_color if conum_font_color
2040
- conum_fragment[:font] = conum_font_name if conum_font_name
2041
- line << conum_fragment
2042
- end
2043
- line << { text: LF } unless last_line
2044
- line
2045
- end
2046
- end
2047
-
2048
- def conum_glyph number
2049
- @conum_glyphs[number - 1]
2050
- end
2051
-
2052
- def convert_table node
2053
- if !at_page_top? && ((unbreakable = node.option? 'unbreakable') || ((node.option? 'breakable') && (node.id || node.title?)))
2054
- (table_container = Block.new (table_dup = node.dup), :open) << table_dup
2055
- if unbreakable
2056
- table_dup.remove_attr 'unbreakable-option'
2057
- table_container.set_attr 'unbreakable-option'
2058
- else
2059
- table_dup.remove_attr 'breakable-option'
1886
+ def convert_table node
1887
+ if !at_page_top? && ((unbreakable = node.option? 'unbreakable') || ((node.option? 'breakable') && (node.id || node.title?)))
1888
+ (table_container = Block.new (table_dup = node.dup), :open) << table_dup
1889
+ if unbreakable
1890
+ table_dup.remove_attr 'unbreakable-option'
1891
+ table_container.set_attr 'unbreakable-option'
1892
+ else
1893
+ table_dup.remove_attr 'breakable-option'
2060
1894
  end
2061
1895
  table_container.id, table_dup.id = table_dup.id, nil
2062
1896
  if table_dup.title?
@@ -2428,10 +2262,7 @@ module Asciidoctor
2428
2262
  return if @toc_extent
2429
2263
  is_macro = (placement = opts[:placement] || 'macro') == 'macro'
2430
2264
  if ((doc = node.document).attr? 'toc-placement', placement) && (doc.attr? 'toc') && doc.sections?
2431
- if (is_book = doc.doctype == 'book')
2432
- start_new_page unless at_page_top?
2433
- start_new_page if @ppbook && verso_page? && !(is_macro && (node.option? 'nonfacing'))
2434
- end
2265
+ start_toc_page node, placement if (is_book = doc.doctype == 'book')
2435
2266
  add_dest_for_block node, id: (node.id || 'toc') if is_macro
2436
2267
  toc_extent = @toc_extent = allocate_toc doc, (doc.attr 'toclevels', 2).to_i, cursor, (title_page_on = is_book || (doc.attr? 'title-page'))
2437
2268
  @index.start_page_number = toc_extent.to.page + 1 if title_page_on && @theme.page_numbering_start_at == 'after-toc'
@@ -2443,74 +2274,42 @@ module Asciidoctor
2443
2274
  nil
2444
2275
  end
2445
2276
 
2446
- # NOTE: to insert sequential page breaks, you must put {nbsp} between page breaks
2447
- def convert_page_break node
2448
- if (page_layout = node.attr 'page-layout').nil_or_empty?
2449
- unless node.role? && (page_layout = (node.roles.map(&:to_sym) & PageLayouts)[-1])
2450
- page_layout = nil
2451
- end
2452
- elsif !(PageLayouts.include? (page_layout = page_layout.to_sym))
2453
- page_layout = nil
2454
- end
2455
-
2456
- if at_page_top?
2457
- if page_layout && page_layout != page.layout && page.empty?
2458
- delete_current_page
2459
- advance_page layout: page_layout
2460
- end
2461
- elsif page_layout
2462
- advance_page layout: page_layout
2277
+ def traverse node, opts = {}
2278
+ # NOTE: need to reconfigure document to use scratch converter in scratch document
2279
+ if self == (prev_converter = node.document.converter)
2280
+ prev_converter = nil
2463
2281
  else
2464
- advance_page
2282
+ node.document.instance_variable_set :@converter, self
2465
2283
  end
2466
- end
2467
-
2468
- def convert_index_section node
2469
- space_needed_for_category = @theme.description_list_term_spacing + (2 * (height_of_typeset_text 'A'))
2470
- pagenum_sequence_style = node.document.attr 'index-pagenum-sequence-style'
2471
- column_box [0, cursor], columns: @theme.index_columns, width: bounds.width, reflow_margins: true, spacer: @theme.index_column_gap do
2472
- @index.categories.each do |category|
2473
- bounds.move_past_bottom if space_needed_for_category > cursor
2474
- ink_prose category.name,
2475
- align: :left,
2476
- inline_format: false,
2477
- margin_bottom: @theme.description_list_term_spacing,
2478
- style: @theme.description_list_term_font_style&.to_sym
2479
- category.terms.each {|term| convert_index_list_item term, pagenum_sequence_style }
2480
- @theme.prose_margin_bottom > cursor ? bounds.move_past_bottom : (move_down @theme.prose_margin_bottom)
2284
+ if node.blocks?
2285
+ node.content
2286
+ elsif node.content_model != :compound && (string = node.content)
2287
+ prose_opts = opts.merge hyphenate: true, margin_bottom: 0
2288
+ if (bottom_gutter = @bottom_gutters[-1][node])
2289
+ prose_opts[:bottom_gutter] = bottom_gutter
2481
2290
  end
2291
+ ink_prose string, prose_opts
2482
2292
  end
2483
- nil
2293
+ ensure
2294
+ node.document.instance_variable_set :@converter, prev_converter if prev_converter
2484
2295
  end
2485
2296
 
2486
- def convert_index_list_item term, pagenum_sequence_style = nil
2487
- text = escape_xml term.name
2488
- unless term.container?
2489
- if @media == 'screen'
2490
- case pagenum_sequence_style
2491
- when 'page'
2492
- pagenums = term.dests.uniq {|dest| dest[:page] }.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
2493
- when 'range'
2494
- first_anchor_per_page = term.dests.each_with_object({}) {|dest, accum| accum[dest[:page]] ||= dest[:anchor] }
2495
- pagenums = (consolidate_ranges first_anchor_per_page.keys).map do |range|
2496
- anchor = first_anchor_per_page[(range.include? '-') ? (range.partition '-')[0] : range]
2497
- %(<a anchor="#{anchor}">#{range}</a>)
2498
- end
2499
- else # term
2500
- pagenums = term.dests.map {|dest| %(<a anchor="#{dest[:anchor]}">#{dest[:page]}</a>) }
2501
- end
2297
+ def traverse_list_item node, list_type, opts = {}
2298
+ if list_type == :dlist # qanda
2299
+ terms, desc = node
2300
+ terms.each {|term| ink_prose %(<em>#{term.text}</em>), (opts.merge margin_bottom: @theme.description_list_term_spacing) }
2301
+ if desc
2302
+ ink_prose desc.text, (opts.merge hyphenate: true) if desc.text?
2303
+ traverse desc
2304
+ end
2305
+ else
2306
+ if (primary_text = node.text).nil_or_empty?
2307
+ ink_prose DummyText, opts unless node.blocks?
2502
2308
  else
2503
- pagenums = consolidate_ranges term.dests.map {|dest| dest[:page] }.uniq
2309
+ ink_prose primary_text, (opts.merge hyphenate: true)
2504
2310
  end
2505
- text = %(#{text}, #{pagenums.join ', '})
2311
+ traverse node
2506
2312
  end
2507
- subterm_indent = @theme.description_list_description_indent
2508
- ink_prose text, align: :left, margin: 0, hanging_indent: subterm_indent * 2
2509
- indent subterm_indent do
2510
- term.subterms.each do |subterm|
2511
- convert_index_list_item subterm, pagenum_sequence_style
2512
- end
2513
- end unless term.leaf?
2514
2313
  end
2515
2314
 
2516
2315
  def convert_inline_anchor node
@@ -2792,193 +2591,118 @@ module Asciidoctor
2792
2591
  node.id ? %(<a id="#{node.id}">#{DummyText}</a>#{quoted_text}) : quoted_text
2793
2592
  end
2794
2593
 
2795
- # Returns a Boolean indicating whether the title page was created
2796
- def ink_title_page doc
2797
- return unless doc.header? && !doc.notitle && @theme.title_page != false
2594
+ # If an id is provided or the node passed as the first argument has an id,
2595
+ # add a named destination to the document equivalent to the node id at the
2596
+ # current y position. If the node does not have an id and an id is not
2597
+ # specified, do nothing.
2598
+ #
2599
+ # If the node is a section, and the current y position is the top of the
2600
+ # page, set the y position equal to the page height to improve the navigation
2601
+ # experience. If the current x position is at or inside the left margin, set
2602
+ # the x position equal to 0 (left edge of page) to improve the navigation
2603
+ # experience.
2604
+ def add_dest_for_block node, id: nil, y: nil
2605
+ if !scratch? && (id ||= node.id)
2606
+ dest_x = bounds.absolute_left.truncate 4
2607
+ # QUESTION: when content is aligned to left margin, should we keep precise x value or just use 0?
2608
+ dest_x = 0 if dest_x <= page_margin_left
2609
+ unless (dest_y = y)
2610
+ dest_y = @y
2611
+ dest_y += [page_height - dest_y, -@theme.block_anchor_top.to_f].min
2612
+ end
2613
+ # TODO: find a way to store only the ref of the destination; look it up when we need it
2614
+ node.set_attr 'pdf-destination', (node_dest = (dest_xyz dest_x, dest_y))
2615
+ add_dest id, node_dest
2616
+ end
2617
+ nil
2618
+ end
2798
2619
 
2799
- # NOTE: a new page may have already been started at this point, so decide what to do with it
2800
- if page.empty?
2801
- page.reset_content if (recycle = @ppbook ? recto_page? : true)
2802
- elsif @ppbook && page_number > 0 && recto_page?
2803
- start_new_page
2620
+ def add_outline doc, num_levels, toc_page_nums, num_front_matter_pages, has_front_cover
2621
+ if ::String === num_levels
2622
+ if num_levels.include? ':'
2623
+ num_levels, expand_levels = num_levels.split ':', 2
2624
+ num_levels = num_levels.empty? ? (doc.attr 'toclevels', 2).to_i : num_levels.to_i
2625
+ expand_levels = expand_levels.to_i
2626
+ else
2627
+ num_levels = expand_levels = num_levels.to_i
2628
+ end
2629
+ else
2630
+ expand_levels = num_levels
2804
2631
  end
2632
+ front_matter_counter = RomanNumeral.new 0, :lower
2633
+ pagenum_labels = {}
2805
2634
 
2806
- side = page_side (recycle ? nil : page_number + 1), @folio_placement[:inverted]
2807
- prev_bg_image = @page_bg_image[side]
2808
- prev_bg_color = @page_bg_color
2809
- if (bg_image = resolve_background_image doc, @theme, 'title-page-background-image')
2810
- @page_bg_image[side] = bg_image[0] && bg_image
2635
+ num_front_matter_pages.times do |n|
2636
+ pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new front_matter_counter.next!.to_s) }
2811
2637
  end
2812
- if (bg_color = resolve_theme_color :title_page_background_color)
2813
- @page_bg_color = bg_color
2638
+
2639
+ # add labels for each content page, which is required for reader's page navigator to work correctly
2640
+ (num_front_matter_pages..(page_count - 1)).each_with_index do |n, i|
2641
+ pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new (i + 1).to_s) }
2814
2642
  end
2815
- recycle ? float { init_page self } : start_new_page
2816
- @page_bg_image[side] = prev_bg_image if bg_image
2817
- @page_bg_color = prev_bg_color if bg_color
2818
2643
 
2819
- # NOTE: this is the first page created, so we must set the base font
2820
- font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style
2644
+ unless toc_page_nums.none? || (toc_title = doc.attr 'toc-title').nil_or_empty?
2645
+ toc_section = insert_toc_section doc, toc_title, toc_page_nums
2646
+ end
2821
2647
 
2822
- # QUESTION: allow alignment per element on title page?
2823
- title_align = (@theme.title_page_text_align || @base_text_align).to_sym
2648
+ outline.define do
2649
+ initial_pagenum = has_front_cover ? 2 : 1
2650
+ # FIXME: use sanitize: :plain_text on Document#doctitle once available
2651
+ if document.page_count >= initial_pagenum && (outline_title = doc.attr 'outline-title') &&
2652
+ (outline_title.empty? ? (outline_title = document.resolve_doctitle doc) : outline_title)
2653
+ page title: (document.sanitize outline_title), destination: (document.dest_top initial_pagenum)
2654
+ end
2655
+ # QUESTION: is there any way to get add_outline_level to invoke in the context of the outline?
2656
+ document.add_outline_level self, doc.sections, num_levels, expand_levels
2657
+ end if doc.attr? 'outline'
2824
2658
 
2825
- if @theme.title_page_logo_display != 'none' && (logo_image_path = (doc.attr 'title-logo-image') || (logo_image_from_theme = @theme.title_page_logo_image))
2826
- if (logo_image_path.include? ':') && logo_image_path =~ ImageAttributeValueRx
2827
- logo_image_attrs = (AttributeList.new $2).parse %w(alt width height)
2828
- if logo_image_from_theme
2829
- relative_to_imagesdir = false
2830
- logo_image_path = apply_subs_discretely doc, $1, subs: [:attributes]
2831
- logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
2832
- else
2833
- relative_to_imagesdir = true
2834
- logo_image_path = $1
2659
+ toc_section&.remove
2660
+
2661
+ catalog.data[:PageLabels] = state.store.ref Nums: pagenum_labels.flatten
2662
+ primary_page_mode, secondary_page_mode = PageModes[(doc.attr 'pdf-page-mode') || @theme.page_mode]
2663
+ catalog.data[:PageMode] = primary_page_mode
2664
+ catalog.data[:NonFullScreenPageMode] = secondary_page_mode if secondary_page_mode
2665
+ nil
2666
+ end
2667
+
2668
+ def add_outline_level outline, sections, num_levels, expand_levels
2669
+ sections.each do |sect|
2670
+ next if (num_levels_for_sect = (sect.attr 'outlinelevels', num_levels).to_i) < (level = sect.level) ||
2671
+ ((sect.option? 'notitle') && sect == sect.document.last_child && sect.empty?)
2672
+ sect_title = sanitize sect.numbered_title formal: true
2673
+ sect_destination = sect.attr 'pdf-destination'
2674
+ if level < num_levels_for_sect && sect.sections?
2675
+ outline.section sect_title, destination: sect_destination, closed: expand_levels < 1 do
2676
+ add_outline_level outline, sect.sections, num_levels_for_sect, (expand_levels - 1)
2835
2677
  end
2836
2678
  else
2837
- logo_image_attrs = {}
2838
- relative_to_imagesdir = false
2839
- if logo_image_from_theme
2840
- logo_image_path = apply_subs_discretely doc, logo_image_path, subs: [:attributes]
2841
- logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
2842
- end
2843
- end
2844
- if (::Asciidoctor::Image.target_and_format logo_image_path)[1] == 'pdf'
2845
- log :error, %(PDF format not supported for title page logo image: #{logo_image_path})
2846
- else
2847
- logo_image_attrs['target'] = logo_image_path
2848
- # NOTE: at the very least, title_align will be a valid alignment value
2849
- logo_image_attrs['align'] = [(logo_image_attrs.delete 'align'), @theme.title_page_logo_align, title_align.to_s].find {|val| (BlockAlignmentNames.include? val) }
2850
- if (logo_image_top = logo_image_attrs['top'] || @theme.title_page_logo_top)
2851
- initial_y, @y = @y, (resolve_top logo_image_top)
2852
- end
2853
- # NOTE: pinned option keeps image on same page
2854
- indent (@theme.title_page_logo_margin_left || 0), (@theme.title_page_logo_margin_right || 0) do
2855
- # FIXME: add API to Asciidoctor for creating blocks outside of extensions
2856
- convert_image (::Asciidoctor::Block.new doc, :image, content_model: :empty, attributes: logo_image_attrs), relative_to_imagesdir: relative_to_imagesdir, pinned: true
2857
- end
2858
- @y = initial_y if initial_y
2859
- end
2860
- end
2861
-
2862
- # TODO: prevent content from spilling to next page
2863
- theme_font :title_page do
2864
- if (title_top = @theme.title_page_title_top)
2865
- @y = resolve_top title_top
2866
- end
2867
- unless @theme.title_page_title_display == 'none'
2868
- doctitle = doc.doctitle partition: true
2869
- move_down @theme.title_page_title_margin_top || 0
2870
- indent (@theme.title_page_title_margin_left || 0), (@theme.title_page_title_margin_right || 0) do
2871
- theme_font :title_page_title do
2872
- ink_prose doctitle.main, align: title_align, margin: 0
2873
- end
2874
- end
2875
- move_down @theme.title_page_title_margin_bottom || 0
2876
- end
2877
- if @theme.title_page_subtitle_display != 'none' && (subtitle = (doctitle || (doc.doctitle partition: true)).subtitle)
2878
- move_down @theme.title_page_subtitle_margin_top || 0
2879
- indent (@theme.title_page_subtitle_margin_left || 0), (@theme.title_page_subtitle_margin_right || 0) do
2880
- theme_font :title_page_subtitle do
2881
- ink_prose subtitle, align: title_align, margin: 0
2882
- end
2883
- end
2884
- move_down @theme.title_page_subtitle_margin_bottom || 0
2885
- end
2886
- if @theme.title_page_authors_display != 'none' && (doc.attr? 'authors')
2887
- move_down @theme.title_page_authors_margin_top || 0
2888
- indent (@theme.title_page_authors_margin_left || 0), (@theme.title_page_authors_margin_right || 0) do
2889
- generic_authors_content = @theme.title_page_authors_content
2890
- authors_content = {
2891
- name_only: @theme.title_page_authors_content_name_only || generic_authors_content,
2892
- with_email: @theme.title_page_authors_content_with_email || generic_authors_content,
2893
- with_url: @theme.title_page_authors_content_with_url || generic_authors_content,
2894
- }
2895
- authors = doc.authors.map.with_index do |author, idx|
2896
- with_author doc, author, idx == 0 do
2897
- author_content_key = (url = doc.attr 'url') ? ((url.start_with? 'mailto:') ? :with_email : :with_url) : :name_only
2898
- if (author_content = authors_content[author_content_key])
2899
- apply_subs_discretely doc, author_content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
2900
- else
2901
- doc.attr 'author'
2902
- end
2903
- end
2904
- end.join @theme.title_page_authors_delimiter
2905
- theme_font :title_page_authors do
2906
- ink_prose authors, align: title_align, margin: 0, normalize: true
2907
- end
2908
- end
2909
- move_down @theme.title_page_authors_margin_bottom || 0
2910
- end
2911
- unless @theme.title_page_revision_display == 'none' || (revision_info = [(doc.attr? 'revnumber') ? %(#{doc.attr 'version-label'} #{doc.attr 'revnumber'}) : nil, (doc.attr 'revdate')].compact).empty?
2912
- move_down @theme.title_page_revision_margin_top || 0
2913
- revision_text = revision_info.join @theme.title_page_revision_delimiter
2914
- if (revremark = doc.attr 'revremark')
2915
- revision_text = %(#{revision_text}: #{revremark})
2916
- end
2917
- indent (@theme.title_page_revision_margin_left || 0), (@theme.title_page_revision_margin_right || 0) do
2918
- theme_font :title_page_revision do
2919
- ink_prose revision_text, align: title_align, margin: 0, normalize: false
2920
- end
2921
- end
2922
- move_down @theme.title_page_revision_margin_bottom || 0
2679
+ outline.page title: sect_title, destination: sect_destination
2923
2680
  end
2924
2681
  end
2925
-
2926
- ink_prose DummyText, margin: 0, line_height: 1, normalize: false if page.empty?
2927
- true
2928
2682
  end
2929
2683
 
2930
- def ink_cover_page doc, face
2931
- image_path, image_opts = resolve_background_image doc, @theme, %(#{face}-cover-image), theme_key: %(cover_#{face}_image).to_sym, symbolic_paths: ['', '~']
2932
- if image_path
2933
- if image_path.empty?
2934
- go_to_page page_count if face == :back
2935
- start_new_page_discretely
2936
- # NOTE: open graphics state to prevent page from being reused
2937
- open_graphics_state if face == :front
2938
- return
2939
- elsif image_path == '~'
2940
- @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress'
2941
- return
2942
- end
2943
-
2944
- go_to_page page_count if face == :back
2945
- if image_opts[:format] == 'pdf'
2946
- import_page image_path, (image_opts.merge advance: face != :back, advance_if_missing: false)
2947
- else
2948
- begin
2949
- image_page image_path, image_opts
2950
- rescue
2951
- log :warn, %(could not embed #{face} cover image: #{image_path}; #{$!.message})
2952
- end
2953
- end
2684
+ def apply_subs_discretely doc, value, opts = {}
2685
+ if (imagesdir = opts[:imagesdir])
2686
+ imagesdir_to_restore = doc.attr 'imagesdir'
2687
+ doc.set_attr 'imagesdir', imagesdir
2954
2688
  end
2955
- end
2956
-
2957
- def stamp_foreground_image doc, has_front_cover
2958
- pages = state.pages
2959
- if (first_page = (has_front_cover ? (pages.slice 1, pages.size) : pages).find {|it| !it.imported_page? }) &&
2960
- (first_page_num = (pages.index first_page) + 1) &&
2961
- (fg_image = resolve_background_image doc, @theme, 'page-foreground-image') && fg_image[0]
2962
- go_to_page first_page_num
2963
- create_stamp 'foreground-image' do
2964
- canvas { image fg_image[0], ({ position: :center, vposition: :center }.merge fg_image[1]) }
2965
- end
2966
- stamp 'foreground-image'
2967
- (first_page_num.next..page_count).each do |num|
2968
- go_to_page num
2969
- stamp 'foreground-image' unless page.imported_page?
2689
+ # FIXME: get sub_attributes to handle drop-line w/o a warning
2690
+ doc.set_attr 'attribute-missing', 'skip' unless (attribute_missing = doc.attr 'attribute-missing') == 'skip'
2691
+ value = value.gsub '\{', '\\\\\\{' if (escaped_attr_ref = value.include? '\{')
2692
+ value = (subs = opts[:subs]) ? (doc.apply_subs value, subs) : (doc.apply_subs value)
2693
+ value = (value.split LF).delete_if {|line| SimpleAttributeRefRx.match? line }.join LF if opts[:drop_lines_with_unresolved_attributes] && (value.include? '{')
2694
+ value = value.gsub '\{', '{' if escaped_attr_ref
2695
+ doc.set_attr 'attribute-missing', attribute_missing unless attribute_missing == 'skip'
2696
+ if imagesdir
2697
+ if imagesdir_to_restore
2698
+ doc.set_attr 'imagesdir', imagesdir_to_restore
2699
+ else
2700
+ doc.remove_attr 'imagesdir'
2970
2701
  end
2971
2702
  end
2703
+ value
2972
2704
  end
2973
2705
 
2974
- def start_new_chapter chapter
2975
- start_new_page unless at_page_top?
2976
- # TODO: must call update_colors before advancing to next page if start_new_page is called in ink_chapter_title
2977
- start_new_page if @ppbook && verso_page? && !(chapter.option? 'nonfacing')
2978
- end
2979
-
2980
- alias start_new_part start_new_chapter
2981
-
2982
2706
  # Position the cursor for where to ink the specified section title or discrete heading node.
2983
2707
  #
2984
2708
  # This method computes whether there is enough room on the page to prevent the specified node
@@ -3001,12 +2725,10 @@ module Asciidoctor
3001
2725
  end
3002
2726
  if page == start_page
3003
2727
  page.tare_content_stream
3004
- orphaned = stop_if_first_page_empty do
3005
- node.context == :section ? (traverse node) : (convert (siblings = node.parent.blocks)[(siblings.index node) + 1])
3006
- end
2728
+ orphaned = stop_if_first_page_empty { node.context == :section ? (traverse node) : (convert node.next_sibling) }
3007
2729
  end
3008
2730
  end
3009
- start_new_page if orphaned
2731
+ advance_page if orphaned
3010
2732
  else
3011
2733
  theme_font :heading, level: (hlevel = opts[:level]) do
3012
2734
  h_padding_t, h_padding_r, h_padding_b, h_padding_l = expand_padding_value @theme[%(heading_h#{hlevel}_padding)]
@@ -3015,176 +2737,179 @@ module Asciidoctor
3015
2737
  heading_h = (height_of_typeset_text title) +
3016
2738
  (@theme[%(heading_h#{hlevel}_margin_top)] || @theme.heading_margin_top) +
3017
2739
  (@theme[%(heading_h#{hlevel}_margin_bottom)] || @theme.heading_margin_bottom) + h_padding_t + h_padding_b
3018
- if (min_height_after = @theme.heading_min_height_after) &&
3019
- (node.context == :section ? node.blocks? : node != node.parent.blocks[-1])
2740
+ if (min_height_after = @theme.heading_min_height_after) && (node.context == :section ? node.blocks? : !node.last_child?)
3020
2741
  heading_h += min_height_after
3021
2742
  end
3022
2743
  cursor >= heading_h
3023
2744
  end
3024
- start_new_page unless h_fits
2745
+ advance_page unless h_fits
3025
2746
  end
3026
2747
  end
3027
2748
  nil
3028
2749
  end
3029
2750
 
3030
- def ink_chapter_title node, title, opts = {}
3031
- ink_general_heading node, title, (opts.merge outdent: true)
2751
+ # NOTE: only used when tabsize attribute is not specified
2752
+ # tabs must always be replaced with spaces in order for the indentation guards to work
2753
+ def expand_tabs string
2754
+ if string.nil_or_empty?
2755
+ ''
2756
+ elsif string.include? TAB
2757
+ full_tab_space = ' ' * (tab_size = 4)
2758
+ (string.split LF, -1).map do |line|
2759
+ if line.empty? || !(tab_idx = line.index TAB)
2760
+ line
2761
+ else
2762
+ if tab_idx == 0
2763
+ leading_tabs = 0
2764
+ line.each_byte do |b|
2765
+ break unless b == 9
2766
+ leading_tabs += 1
2767
+ end
2768
+ line = %(#{full_tab_space * leading_tabs}#{rest = line.slice leading_tabs, line.length})
2769
+ next line unless rest.include? TAB
2770
+ end
2771
+ # keeps track of how many spaces were added to adjust offset in match data
2772
+ spaces_added = 0
2773
+ idx = 0
2774
+ result = ''
2775
+ line.each_char do |c|
2776
+ if c == TAB
2777
+ # calculate how many spaces this tab represents, then replace tab with spaces
2778
+ if (offset = idx + spaces_added) % tab_size == 0
2779
+ spaces_added += (tab_size - 1)
2780
+ result += full_tab_space
2781
+ else
2782
+ unless (spaces = tab_size - offset % tab_size) == 1
2783
+ spaces_added += (spaces - 1)
2784
+ end
2785
+ result += (' ' * spaces)
2786
+ end
2787
+ else
2788
+ result += c
2789
+ end
2790
+ idx += 1
2791
+ end
2792
+ result
2793
+ end
2794
+ end.join LF
2795
+ else
2796
+ string
2797
+ end
3032
2798
  end
3033
2799
 
3034
- alias ink_part_title ink_chapter_title
3035
-
3036
- def ink_general_heading _node, title, opts = {}
3037
- ink_heading title, opts
2800
+ # Extract callout marks from string, indexed by 0-based line number
2801
+ # Return an Array with the processed string as the first argument
2802
+ # and the mapping of lines to conums as the second.
2803
+ def extract_conums string
2804
+ conum_mapping = {}
2805
+ auto_num = 0
2806
+ string = (string.split LF).map.with_index do |line, line_num|
2807
+ # FIXME: we get extra spaces before numbers if more than one on a line
2808
+ if line.include? '<'
2809
+ line = line.gsub CalloutExtractRx do
2810
+ # honor the escape
2811
+ if $1 == ?\\
2812
+ $&.sub $1, ''
2813
+ else
2814
+ (conum_mapping[line_num] ||= []) << ($3 == '.' ? (auto_num += 1) : $3.to_i)
2815
+ ''
2816
+ end
2817
+ end
2818
+ # NOTE: use first position to store space that precedes conums
2819
+ if (conum_mapping.key? line_num) && (line.end_with? ' ')
2820
+ trimmed_line = line.rstrip
2821
+ conum_mapping[line_num].unshift line.slice trimmed_line.length, line.length
2822
+ line = trimmed_line
2823
+ end
2824
+ end
2825
+ line
2826
+ end.join LF
2827
+ conum_mapping = nil if conum_mapping.empty?
2828
+ [string, conum_mapping]
3038
2829
  end
3039
2830
 
3040
- # NOTE: ink_heading doesn't set the theme font because it's used for various types of headings
3041
- def ink_heading string, opts = {}
3042
- if (h_level = opts[:level])
3043
- h_category = %(heading_h#{h_level})
3044
- end
3045
- unless (top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top))
3046
- if at_page_top?
3047
- if h_category && (top_margin = @theme[%(#{h_category}_margin_page_top)] || @theme.heading_margin_page_top) > 0
3048
- move_down top_margin
2831
+ # Restore the conums into the Array of formatted text fragments
2832
+ #--
2833
+ # QUESTION: can this be done more efficiently?
2834
+ # QUESTION: can we reuse arrange_fragments_by_line?
2835
+ def restore_conums fragments, conum_mapping, linenums = nil, highlight_lines = nil
2836
+ lines = []
2837
+ line_num = 0
2838
+ # reorganize the fragments into an array of lines
2839
+ fragments.each do |fragment|
2840
+ line = (lines[line_num] ||= [])
2841
+ if (text = fragment[:text]) == LF
2842
+ lines[line_num += 1] ||= []
2843
+ elsif text.include? LF
2844
+ (text.split LF, -1).each_with_index do |line_in_fragment, idx|
2845
+ line = (lines[line_num += 1] ||= []) unless idx == 0
2846
+ line << (fragment.merge text: line_in_fragment) unless line_in_fragment.empty?
3049
2847
  end
3050
- top_margin = 0
3051
2848
  else
3052
- top_margin = (h_category ? @theme[%(#{h_category}_margin_top)] : nil) || @theme.heading_margin_top
2849
+ line << fragment
3053
2850
  end
3054
2851
  end
3055
- bot_margin = margin || (opts.delete :margin_bottom) || (h_category ? @theme[%(#{h_category}_margin_bottom)] : nil) || @theme.heading_margin_bottom
3056
- if (transform = resolve_text_transform opts)
3057
- string = transform_text string, transform
2852
+ conum_font_color = @theme.conum_font_color
2853
+ if (conum_font_name = @theme.conum_font_family) == font_name
2854
+ conum_font_name = nil
3058
2855
  end
3059
- outdent_section opts.delete :outdent do
3060
- margin_top top_margin
3061
- start_cursor = cursor
3062
- start_page_number = page_number
3063
- pad_box h_category ? @theme[%(#{h_category}_padding)] : nil do
3064
- # QUESTION: should we move inherited styles to typeset_text?
3065
- if (inherited = apply_text_decoration font_styles, :heading, h_level).empty?
3066
- inline_format_opts = true
3067
- else
3068
- inline_format_opts = [{ inherited: inherited }]
3069
- end
3070
- typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3071
- color: @font_color,
3072
- inline_format: inline_format_opts,
3073
- align: @base_text_align.to_sym,
3074
- }.merge(opts)
2856
+ last_line_num = lines.size - 1
2857
+ if linenums
2858
+ pad_size = (last_line_num + 1).to_s.length
2859
+ linenum_color = @theme.code_linenum_font_color
2860
+ end
2861
+ # append conums to appropriate lines, then flatten to an array of fragments
2862
+ lines.flat_map.with_index do |line, cur_line_num|
2863
+ last_line = cur_line_num == last_line_num
2864
+ visible_line_num = cur_line_num + (linenums || 1)
2865
+ if highlight_lines && (highlight_bg_color = highlight_lines[visible_line_num])
2866
+ line.unshift text: DummyText, background_color: highlight_bg_color, highlight: true, inline_block: true, extend: true, width: 0, callback: [FormattedText::TextBackgroundAndBorderRenderer]
3075
2867
  end
3076
- if h_category && @theme[%(#{h_category}_border_width)] &&
3077
- (@theme[%(#{h_category}_border_color)] || @theme.base_border_color) && page_number == start_page_number
3078
- float do
3079
- bounding_box [bounds.left, start_cursor], width: bounds.width, height: start_cursor - cursor do
3080
- theme_fill_and_stroke_bounds h_category
3081
- end
3082
- end
2868
+ line.unshift text: %(#{visible_line_num.to_s.rjust pad_size} ), linenum: visible_line_num, color: linenum_color if linenums
2869
+ if conum_mapping && (conums = conum_mapping.delete cur_line_num)
2870
+ line << { text: conums.shift } if ::String === conums[0]
2871
+ conum_text = conums.map {|num| conum_glyph num }.join ' '
2872
+ conum_fragment = { text: conum_text }
2873
+ conum_fragment[:color] = conum_font_color if conum_font_color
2874
+ conum_fragment[:font] = conum_font_name if conum_font_name
2875
+ line << conum_fragment
3083
2876
  end
3084
- margin_bottom bot_margin
2877
+ line << { text: LF } unless last_line
2878
+ line
3085
2879
  end
3086
2880
  end
3087
2881
 
3088
- # private
3089
- def ink_paragraph_in_float_box node, float_box, prose_opts, role_keys, block_next, insert_margin_bottom
3090
- @float_box = para_font_descender = para_font_size = end_cursor = nil
3091
- if role_keys
3092
- line_metrics = theme_font_cascade role_keys do
3093
- para_font_descender = font.descender
3094
- para_font_size = font_size
3095
- calc_line_metrics @base_line_height
3096
- end
3097
- else
3098
- para_font_descender = font.descender
3099
- para_font_size = font_size
3100
- line_metrics = calc_line_metrics @base_line_height
3101
- end
3102
- # allocate the space of at least one empty line below block
3103
- line_height_length = line_metrics.height + line_metrics.leading + line_metrics.padding_top
3104
- start_page_number = float_box[:page]
3105
- start_cursor = cursor
3106
- block_bottom = (float_box_bottom = float_box[:bottom]) + float_box[:gap][1]
3107
- # use :at to incorporate padding top from line metrics
3108
- # use :final_gap to incorporate padding bottom from line metrics
3109
- # use :draw_text_callback to track end cursor (requires applying :final_gap to result manually)
3110
- prose_opts.update \
3111
- at: [float_box[:left], start_cursor - line_metrics.padding_top],
3112
- width: float_box[:width],
3113
- height: [cursor, float_box[:height] - (float_box[:top] - start_cursor) + line_height_length].min,
3114
- final_gap: para_font_descender + line_metrics.padding_bottom,
3115
- draw_text_callback: (proc do |text, opts|
3116
- draw_text! text, opts
3117
- end_cursor = opts[:at][1] # does not include :final_gap value
3118
- end)
3119
- overflow_text = role_keys ?
3120
- theme_font_cascade(role_keys) { ink_prose node.content, prose_opts } :
3121
- (ink_prose node.content, prose_opts)
3122
- move_cursor_to end_cursor -= prose_opts[:final_gap] if end_cursor # ink_prose with :height does not move cursor
3123
- if overflow_text.empty?
3124
- if block_next && (supports_float_wrapping? block_next)
3125
- insert_margin_bottom.call
3126
- @float_box = float_box if page_number == start_page_number && cursor > start_cursor - prose_opts[:height]
3127
- elsif end_cursor > block_bottom
3128
- move_cursor_to block_bottom
3129
- theme_margin :block, :bottom, block_next
3130
- else
3131
- insert_margin_bottom.call
3132
- end
3133
- else
3134
- overflow_prose_opts = { align: prose_opts[:align] || @base_text_align.to_sym }
3135
- unless end_cursor
3136
- overflow_prose_opts[:indent_paragraphs] = prose_opts[:indent_paragraphs]
3137
- move_cursor_to float_box_bottom if start_cursor > float_box_bottom
3138
- end
3139
- role_keys ?
3140
- theme_font_cascade(role_keys) { typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts } :
3141
- (typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts)
3142
- insert_margin_bottom.call
3143
- end
2882
+ def fallback_svg_font_name
2883
+ @theme.svg_fallback_font_family || @theme.svg_font_family || @theme.base_font_family
3144
2884
  end
3145
2885
 
3146
- # NOTE: inline_format is true by default
3147
- def ink_prose string, opts = {}
3148
- top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || 0
3149
- bot_margin = margin || (opts.delete :margin_bottom) || @theme.prose_margin_bottom
3150
- if (transform = resolve_text_transform opts)
3151
- string = transform_text string, transform
3152
- end
3153
- string = hyphenate_text string, @hyphenator if (opts.delete :hyphenate) && (defined? @hyphenator)
3154
- # NOTE: used by extensions; ensures linked text gets formatted using the link styles
3155
- if (anchor = opts.delete :anchor)
3156
- string = anchor == true ? %(<a>#{string}</a>) : %(<a anchor="#{anchor}">#{string}</a>)
3157
- end
3158
- margin_top top_margin
3159
- # NOTE: normalize makes endlines soft (replaces "\n" with ' ')
3160
- inline_format_opts = { normalize: (opts.delete :normalize) != false }
3161
- if (styles = opts.delete :styles)
3162
- inline_format_opts[:inherited] = {
3163
- styles: styles,
3164
- text_decoration_color: (opts.delete :text_decoration_color),
3165
- text_decoration_width: (opts.delete :text_decoration_width),
3166
- }.compact
2886
+ # Add an indentation guard at the start of indented lines.
2887
+ # Expand tabs to spaces if tabs are present
2888
+ def guard_indentation string
2889
+ unless (string = expand_tabs string).empty?
2890
+ string[0] = GuardedIndent if string.start_with? ' '
2891
+ string.gsub! InnerIndent, GuardedInnerIndent if string.include? InnerIndent
3167
2892
  end
3168
- result = typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3169
- color: @font_color,
3170
- inline_format: [inline_format_opts],
3171
- align: @base_text_align.to_sym,
3172
- }.merge(opts)
3173
- margin_bottom bot_margin
3174
- result
2893
+ string
3175
2894
  end
3176
2895
 
3177
- def generate_manname_section node
3178
- title = node.attr 'manname-title', 'Name'
3179
- if (next_section_title = node.sections[0]&.title) && next_section_title.upcase == next_section_title
3180
- title = title.upcase
2896
+ def guard_indentation_in_fragments fragments
2897
+ start_of_line = true
2898
+ fragments.each do |fragment|
2899
+ next if (text = fragment[:text]).empty?
2900
+ if start_of_line && (text.start_with? ' ')
2901
+ fragment[:text] = GuardedIndent + (((text = text.slice 1, text.length).include? InnerIndent) ? (text.gsub InnerIndent, GuardedInnerIndent) : text)
2902
+ elsif text.include? InnerIndent
2903
+ fragment[:text] = text.gsub InnerIndent, GuardedInnerIndent
2904
+ end
2905
+ start_of_line = text.end_with? LF
3181
2906
  end
3182
- sect = Section.new node, 1
3183
- sect.sectname = 'section'
3184
- sect.id = node.attr 'manname-id'
3185
- sect.title = title
3186
- sect << (Block.new sect, :paragraph, source: %(#{node.attr 'manname'} - #{node.attr 'manpurpose'}), subs: :normal)
3187
- sect
2907
+ fragments
2908
+ end
2909
+
2910
+ def height_of_typeset_text string, opts = {}
2911
+ line_metrics = (calc_line_metrics opts[:line_height] || @base_line_height)
2912
+ (height_of string, leading: line_metrics.leading, final_gap: line_metrics.final_gap) + line_metrics.padding_top + (opts[:single_line] ? 0 : line_metrics.padding_bottom)
3188
2913
  end
3189
2914
 
3190
2915
  # Render the caption in the current document. If the dry_run option is true, return the height.
@@ -3277,7 +3002,7 @@ module Asciidoctor
3277
3002
  end
3278
3003
  unless scratch? || !(bg_color = @theme[%(#{category_caption}_background_color)] || @theme.caption_background_color)
3279
3004
  caption_height = height_of_typeset_text string
3280
- fill_at = [0, cursor + (margin[:top] || 0)]
3005
+ fill_at = [bounds.left, cursor + (margin[:top] || 0)]
3281
3006
  float { bounding_box(fill_at, width: container_width, height: caption_height) { fill_bounds bg_color } }
3282
3007
  end
3283
3008
  indent(*indent_by) do
@@ -3299,389 +3024,148 @@ module Asciidoctor
3299
3024
  ink_caption node, category: :table, end: end_, block_align: table_alignment, block_width: table_width, max_width: max_width
3300
3025
  end
3301
3026
 
3302
- def allocate_toc doc, toc_num_levels, toc_start_cursor, title_page_on
3303
- toc_start_page_number = page_number
3304
- to_page = nil
3305
- extent = dry_run onto: self do
3306
- to_page = (ink_toc doc, toc_num_levels, toc_start_page_number, toc_start_cursor).end
3307
- margin_bottom @theme.block_margin_bottom unless title_page_on
3308
- end
3309
- # NOTE: patch for custom converters that allocate extra TOC pages without actually creating them
3310
- if to_page > extent.to.page
3311
- extent.to.page = to_page
3312
- extent.to.cursor = bounds.height
3313
- end
3314
- # NOTE: reserve pages for the toc; leaves cursor on page after last page in toc
3315
- if title_page_on
3316
- extent.each_page { start_new_page }
3317
- else
3318
- extent.each_page {|first_page| start_new_page unless first_page }
3319
- move_cursor_to extent.to.cursor
3320
- end
3321
- extent
3027
+ def ink_chapter_title node, title, opts = {}
3028
+ ink_general_heading node, title, (opts.merge outdent: true)
3322
3029
  end
3323
3030
 
3324
- def get_entries_for_toc node
3325
- node.sections
3326
- end
3031
+ alias ink_part_title ink_chapter_title
3327
3032
 
3328
- # NOTE: num_front_matter_pages not used during a dry run
3329
- def ink_toc doc, num_levels, toc_page_number, start_cursor, num_front_matter_pages = 0
3330
- go_to_page toc_page_number unless (page_number == toc_page_number) || scratch?
3331
- start_page_number = page_number
3332
- move_cursor_to start_cursor
3333
- unless (toc_title = doc.attr 'toc-title').nil_or_empty?
3334
- theme_font_cascade [[:heading, level: 2], :toc_title] do
3335
- toc_title_align = (@theme.toc_title_text_align || @theme.heading_h2_text_align || @theme.heading_text_align || @base_text_align).to_sym
3336
- ink_general_heading doc, toc_title, align: toc_title_align, level: 2, outdent: true, role: :toctitle
3033
+ def ink_cover_page doc, face
3034
+ image_path, image_opts = resolve_background_image doc, @theme, %(#{face}-cover-image), theme_key: %(cover_#{face}_image).to_sym, symbolic_paths: ['', '~']
3035
+ if image_path
3036
+ if image_path.empty?
3037
+ go_to_page page_count if face == :back
3038
+ start_new_page_discretely
3039
+ # NOTE: open graphics state to prevent page from being reused
3040
+ open_graphics_state if face == :front
3041
+ return
3042
+ elsif image_path == '~'
3043
+ @page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress'
3044
+ return
3337
3045
  end
3338
- end
3339
- # QUESTION: should we skip this whole method if num_levels < 0?
3340
- unless num_levels < 0
3341
- dot_leader = theme_font :toc do
3342
- # TODO: we could simplify by using nested theme_font :toc_dot_leader
3343
- if (dot_leader_font_style = @theme.toc_dot_leader_font_style&.to_sym || :normal) != font_style
3344
- font_style dot_leader_font_style
3046
+
3047
+ go_to_page page_count if face == :back
3048
+ if image_opts[:format] == 'pdf'
3049
+ import_page image_path, (image_opts.merge advance: face != :back, advance_if_missing: false)
3050
+ else
3051
+ begin
3052
+ image_page image_path, image_opts
3053
+ rescue
3054
+ log :warn, %(could not embed #{face} cover image: #{image_path}; #{$!.message})
3345
3055
  end
3346
- font_size @theme.toc_dot_leader_font_size
3347
- {
3348
- font_color: @theme.toc_dot_leader_font_color || @font_color,
3349
- font_style: dot_leader_font_style,
3350
- font_size: font_size,
3351
- levels: ((dot_leader_l = @theme.toc_dot_leader_levels) == 'none' ? ::Set.new :
3352
- (dot_leader_l && dot_leader_l != 'all' ? dot_leader_l.to_s.split.map(&:to_i).to_set : (0..num_levels).to_set)),
3353
- text: (dot_leader_text = @theme.toc_dot_leader_content || DotLeaderTextDefault),
3354
- width: dot_leader_text.empty? ? 0 : (rendered_width_of_string dot_leader_text),
3355
- # TODO: spacer gives a little bit of room between dots and page number
3356
- spacer: { text: NoBreakSpace, size: (spacer_font_size = @font_size * 0.25) },
3357
- spacer_width: (rendered_width_of_char NoBreakSpace, size: spacer_font_size),
3358
- }
3359
3056
  end
3360
- theme_margin :toc, :top
3361
- ink_toc_level (get_entries_for_toc doc), num_levels, dot_leader, num_front_matter_pages
3362
3057
  end
3363
- # NOTE: range must be calculated relative to toc_page_number; absolute page number in scratch document is arbitrary
3364
- toc_page_numbers = (toc_page_number..(toc_page_number + (page_number - start_page_number)))
3365
- go_to_page page_count unless scratch?
3366
- toc_page_numbers
3367
3058
  end
3368
3059
 
3369
- def ink_toc_level entries, num_levels, dot_leader, num_front_matter_pages
3370
- # NOTE: font options aren't always reliable, so store size separately
3371
- toc_font_info = theme_font :toc do
3372
- { font: font, size: @font_size }
3373
- end
3374
- hanging_indent = @theme.toc_hanging_indent
3375
- entries.each do |entry|
3376
- next if (num_levels_for_entry = (entry.attr 'toclevels', num_levels).to_i) < (entry_level = entry.level + 1).pred
3377
- theme_font :toc, level: entry_level do
3378
- next unless (entry_anchor = (entry.attr 'pdf-anchor') || entry.id)
3379
- entry_title = entry.context == :section ? entry.numbered_title : (entry.title? ? entry.title : (entry.xreftext 'basic'))
3380
- next if entry_title.empty?
3381
- entry_title = transform_text entry_title, @text_transform if @text_transform
3382
- pgnum_label_placeholder_width = rendered_width_of_string '0' * @toc_max_pagenum_digits
3383
- # NOTE: only write title (excluding dots and page number) if this is a dry run
3384
- if scratch?
3385
- indent 0, pgnum_label_placeholder_width do
3386
- # NOTE: must wrap title in empty anchor element in case links are styled with different font family / size
3387
- ink_prose entry_title, anchor: true, normalize: false, hanging_indent: hanging_indent, normalize_line_height: true, margin: 0
3388
- end
3389
- else
3390
- if !(physical_pgnum = entry.attr 'pdf-page-start') &&
3391
- (target_page_ref = (get_dest entry_anchor)&.first) &&
3392
- (target_page_idx = state.pages.index {|candidate| candidate.dictionary == target_page_ref })
3393
- physical_pgnum = target_page_idx + 1
3394
- end
3395
- if physical_pgnum
3396
- virtual_pgnum = physical_pgnum - num_front_matter_pages
3397
- pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new physical_pgnum, :lower) : virtual_pgnum).to_s
3398
- else
3399
- pgnum_label = '?'
3400
- end
3401
- start_page_number = page_number
3402
- start_cursor = cursor
3403
- start_dots = nil
3404
- entry_title_inherited = (apply_text_decoration ::Set.new, :toc, entry_level).merge anchor: entry_anchor, color: @font_color
3405
- # NOTE: use text formatter to add anchor overlay to avoid using inline format with synthetic anchor tag
3406
- entry_title_fragments = text_formatter.format entry_title, inherited: entry_title_inherited
3407
- line_metrics = calc_line_metrics @base_line_height
3408
- indent 0, pgnum_label_placeholder_width do
3409
- (entry_title_fragments[-1][:callback] ||= []) << (last_fragment_pos = ::Asciidoctor::PDF::FormattedText::FragmentPositionRenderer.new)
3410
- typeset_formatted_text entry_title_fragments, line_metrics, hanging_indent: hanging_indent, normalize_line_height: true
3411
- start_dots = last_fragment_pos.right + hanging_indent
3412
- last_fragment_cursor = last_fragment_pos.top + line_metrics.padding_top
3413
- start_cursor = last_fragment_cursor if last_fragment_pos.page_number > start_page_number || (start_cursor - last_fragment_cursor) > line_metrics.height
3414
- end
3415
- end_cursor = cursor
3416
- move_cursor_to start_cursor
3417
- # NOTE: we're guaranteed to be on the same page as the final line of the entry
3418
- if dot_leader[:width] > 0 && (dot_leader[:levels].include? entry_level.pred)
3419
- pgnum_label_width = rendered_width_of_string pgnum_label
3420
- pgnum_label_font_settings = { color: @font_color, font: font_family, size: @font_size, styles: font_styles }
3421
- save_font do
3422
- # NOTE: the same font is used for dot leaders throughout toc
3423
- set_font toc_font_info[:font], dot_leader[:font_size]
3424
- font_style dot_leader[:font_style]
3425
- num_dots = [((bounds.width - start_dots - dot_leader[:spacer_width] - pgnum_label_width) / dot_leader[:width]).floor, 0].max
3426
- # FIXME: dots don't line up in columns if width of page numbers differ
3427
- typeset_formatted_text [
3428
- { text: dot_leader[:text] * num_dots, color: dot_leader[:font_color] },
3429
- dot_leader[:spacer],
3430
- ({ text: pgnum_label, anchor: entry_anchor }.merge pgnum_label_font_settings),
3431
- ], line_metrics, align: :right
3432
- end
3433
- else
3434
- typeset_formatted_text [{ text: pgnum_label, color: @font_color, anchor: entry_anchor }], line_metrics, align: :right
3060
+ # QUESTION: if a footnote ref appears in a separate chapter, should the footnote def be duplicated?
3061
+ def ink_footnotes node
3062
+ return if (fns = (doc = node.document).footnotes - @rendered_footnotes).empty?
3063
+ theme_margin :block, :bottom if node.context == :document || node == node.document.last_child
3064
+ theme_margin :footnotes, :top
3065
+ with_dry_run do |extent|
3066
+ if (single_page_height = extent&.single_page_height) && (delta = cursor - single_page_height - 0.0001) > 0
3067
+ move_down delta
3068
+ end
3069
+ theme_font :footnotes do
3070
+ (title = doc.attr 'footnotes-title') && (ink_caption title, category: :footnotes)
3071
+ item_spacing = @theme.footnotes_item_spacing
3072
+ index_offset = @rendered_footnotes.length
3073
+ sect_xreftext = node.context == :section && (node.xreftext node.document.attr 'xrefstyle')
3074
+ fns.each do |fn|
3075
+ label = (index = fn.index) - index_offset
3076
+ if sect_xreftext
3077
+ fn.singleton_class.send :attr_accessor, :label unless fn.respond_to? :label=
3078
+ fn.label = %(#{label} - #{sect_xreftext})
3435
3079
  end
3436
- move_cursor_to end_cursor
3080
+ ink_prose %(<a id="_footnotedef_#{index}">#{DummyText}</a>[<a anchor="_footnoteref_#{index}">#{label}</a>] #{fn.text}), margin_bottom: item_spacing, hyphenate: true
3437
3081
  end
3082
+ @rendered_footnotes += fns if extent
3438
3083
  end
3439
- indent @theme.toc_indent do
3440
- ink_toc_level (get_entries_for_toc entry), num_levels_for_entry, dot_leader, num_front_matter_pages
3441
- end if num_levels_for_entry >= entry_level
3442
3084
  end
3085
+ nil
3443
3086
  end
3444
3087
 
3445
- # Reduce icon height to fit inside bounds.height. Icons will not render
3446
- # properly if they are larger than the current bounds.height.
3447
- def fit_icon_to_bounds preferred_size
3448
- (max_height = bounds.height) < preferred_size ? max_height : preferred_size
3088
+ def ink_general_heading _node, title, opts = {}
3089
+ ink_heading title, opts
3449
3090
  end
3450
3091
 
3451
- def admonition_icon_data key
3452
- if (icon_data = @theme[%(admonition_icon_#{key})])
3453
- icon_data = (AdmonitionIcons[key] || {}).merge icon_data
3454
- if (icon_name = icon_data[:name])
3455
- unless icon_name.start_with?(*IconSetPrefixes)
3456
- log(:info) { %(#{key} admonition in theme uses icon from deprecated fa icon set; use fas, far, or fab instead) }
3457
- icon_data[:name] = %(fa-#{icon_name}) unless icon_name.start_with? 'fa-'
3092
+ # NOTE: ink_heading doesn't set the theme font because it's used for various types of headings
3093
+ def ink_heading string, opts = {}
3094
+ if (h_level = opts[:level])
3095
+ h_category = %(heading_h#{h_level})
3096
+ end
3097
+ unless (top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top))
3098
+ if at_page_top?
3099
+ if h_category && (top_margin = @theme[%(#{h_category}_margin_page_top)] || @theme.heading_margin_page_top) > 0
3100
+ move_down top_margin
3458
3101
  end
3102
+ top_margin = 0
3459
3103
  else
3460
- icon_data[:name] = AdmonitionIcons[:note][:name]
3104
+ top_margin = (h_category ? @theme[%(#{h_category}_margin_top)] : nil) || @theme.heading_margin_top
3461
3105
  end
3462
- else
3463
- (icon_data = AdmonitionIcons[key] || {})[:name] ||= AdmonitionIcons[:note][:name]
3464
3106
  end
3465
- icon_data
3466
- end
3467
-
3468
- # TODO: delegate to ink_page_header and ink_page_footer per page
3469
- def ink_running_content periphery, doc, skip = [1, 1], body_start_page_number = 1
3470
- skip_pages, skip_pagenums = skip
3471
- # NOTE: find and advance to first non-imported content page to use as model page
3472
- return unless (content_start_page_number = state.pages[skip_pages..-1].index {|it| !it.imported_page? })
3473
- content_start_page_number += (skip_pages + 1)
3474
- num_pages = page_count
3475
- prev_page_number = page_number
3476
- go_to_page content_start_page_number
3477
-
3478
- # FIXME: probably need to treat doctypes differently
3479
- is_book = doc.doctype == 'book'
3480
- header = doc.header? ? doc.header : nil
3481
- sectlevels = (@theme[%(#{periphery}_sectlevels)] || 2).to_i
3482
- sections = doc.find_by(context: :section) {|sect| sect.level <= sectlevels && sect != header }
3483
- toc_title = (doc.attr 'toc-title').to_s if (toc_page_nums = @toc_extent&.page_range)
3484
- disable_on_pages = @disable_running_content[periphery]
3485
-
3486
- title_method = TitleStyles[@theme[%(#{periphery}_title_style)]]
3487
- # FIXME: we need a proper model for all this page counting
3488
- # FIXME: we make a big assumption that part & chapter start on new pages
3489
- # index parts, chapters and sections by the physical page number on which they start
3490
- part_start_pages = {}
3491
- chapter_start_pages = {}
3492
- section_start_pages = {}
3493
- trailing_section_start_pages = {}
3494
- sections.each do |sect|
3495
- pgnum = (sect.attr 'pdf-page-start').to_i
3496
- if is_book && ((sect_is_part = sect.sectname == 'part') || sect.level == 1)
3497
- if sect_is_part
3498
- part_start_pages[pgnum] ||= sect
3107
+ bot_margin = margin || (opts.delete :margin_bottom) || (h_category ? @theme[%(#{h_category}_margin_bottom)] : nil) || @theme.heading_margin_bottom
3108
+ if (transform = resolve_text_transform opts)
3109
+ string = transform_text string, transform
3110
+ end
3111
+ outdent_section opts.delete :outdent do
3112
+ margin_top top_margin
3113
+ start_cursor = cursor
3114
+ start_page_number = page_number
3115
+ pad_box h_category ? @theme[%(#{h_category}_padding)] : nil do
3116
+ # QUESTION: should we move inherited styles to typeset_text?
3117
+ if (inherited = apply_text_decoration font_styles, :heading, h_level).empty?
3118
+ inline_format_opts = true
3499
3119
  else
3500
- chapter_start_pages[pgnum] ||= sect
3501
- # FIXME: need a better way to indicate that part has ended
3502
- part_start_pages[pgnum] = '' if sect.sectname == 'appendix' && !part_start_pages.empty?
3120
+ inline_format_opts = [{ inherited: inherited }]
3503
3121
  end
3504
- else
3505
- trailing_section_start_pages[pgnum] = sect
3506
- section_start_pages[pgnum] ||= sect
3507
- end
3508
- end
3509
-
3510
- # index parts, chapters, and sections by the physical page number on which they appear
3511
- parts_by_page = SectionInfoByPage.new title_method
3512
- chapters_by_page = SectionInfoByPage.new title_method
3513
- sections_by_page = SectionInfoByPage.new title_method
3514
- # QUESTION: should the default part be the doctitle?
3515
- last_part = nil
3516
- # QUESTION: should we enforce that the preamble is a preface?
3517
- last_chap = is_book ? :pre : nil
3518
- last_sect = nil
3519
- sect_search_threshold = 1
3520
- (1..num_pages).each do |pgnum|
3521
- if (part = part_start_pages[pgnum])
3522
- last_part = part
3523
- last_chap = nil
3524
- last_sect = nil
3525
- end
3526
- if (chap = chapter_start_pages[pgnum])
3527
- last_chap = chap
3528
- last_sect = nil
3122
+ typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3123
+ color: @font_color,
3124
+ inline_format: inline_format_opts,
3125
+ align: @base_text_align.to_sym,
3126
+ }.merge(opts)
3529
3127
  end
3530
- if (sect = section_start_pages[pgnum])
3531
- last_sect = sect
3532
- elsif part || chap
3533
- sect_search_threshold = pgnum
3534
- # NOTE: we didn't find a section on this page; look back to find last section started
3535
- elsif last_sect
3536
- (sect_search_threshold..(pgnum - 1)).reverse_each do |prev|
3537
- if (sect = trailing_section_start_pages[prev])
3538
- last_sect = sect
3539
- break
3128
+ if h_category && @theme[%(#{h_category}_border_width)] &&
3129
+ (@theme[%(#{h_category}_border_color)] || @theme.base_border_color) && page_number == start_page_number
3130
+ float do
3131
+ bounding_box [bounds.left, start_cursor], width: bounds.width, height: start_cursor - cursor do
3132
+ theme_fill_and_stroke_bounds h_category
3540
3133
  end
3541
3134
  end
3542
3135
  end
3543
- parts_by_page[pgnum] = last_part
3544
- if toc_page_nums&.cover? pgnum
3545
- if is_book
3546
- chapters_by_page[pgnum] = toc_title
3547
- sections_by_page[pgnum] = nil
3548
- else
3549
- chapters_by_page[pgnum] = nil
3550
- sections_by_page[pgnum] = section_start_pages[pgnum] || toc_title
3551
- end
3552
- toc_page_nums = nil if toc_page_nums.end == pgnum
3553
- elsif last_chap == :pre
3554
- chapters_by_page[pgnum] = pgnum < body_start_page_number ? doc.doctitle : (doc.attr 'preface-title', 'Preface')
3555
- sections_by_page[pgnum] = last_sect
3556
- else
3557
- chapters_by_page[pgnum] = last_chap
3558
- sections_by_page[pgnum] = last_sect
3559
- end
3136
+ margin_bottom bot_margin
3560
3137
  end
3138
+ end
3561
3139
 
3562
- doctitle = resolve_doctitle doc, true
3563
- # NOTE: set doctitle again so it's properly escaped
3564
- doc.set_attr 'doctitle', doctitle.combined
3565
- doc.set_attr 'document-title', doctitle.main
3566
- doc.set_attr 'document-subtitle', doctitle.subtitle
3567
- doc.set_attr 'page-count', (num_pages - skip_pagenums)
3568
-
3569
- pagenums_enabled = doc.attr? 'pagenums'
3570
- periphery_layout_cache = {}
3571
- # NOTE: this block is invoked during PDF generation, after #write -> #render_file and thus after #convert_document
3572
- repeat (content_start_page_number..num_pages), dynamic: true do
3573
- pgnum = page_number
3574
- # NOTE: don't write on pages which are imported / inserts (otherwise we can get a corrupt PDF)
3575
- next if page.imported_page? || (disable_on_pages.include? pgnum)
3576
- virtual_pgnum = pgnum - skip_pagenums
3577
- pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new pgnum, :lower) : virtual_pgnum).to_s
3578
- side = page_side((@folio_placement[:basis] == :physical ? pgnum : virtual_pgnum), @folio_placement[:inverted])
3579
- doc.set_attr 'page-layout', page.layout.to_s
3580
-
3581
- # NOTE: running content is cached per page layout
3582
- # QUESTION: should allocation be per side?
3583
- trim_styles, colspec_dict, content_dict, stamp_names = allocate_running_content_layout doc, page, periphery, periphery_layout_cache
3584
- # FIXME: we need to have a content setting for chapter pages
3585
- content_by_position, colspec_by_position = content_dict[side], colspec_dict[side]
3586
-
3587
- doc.set_attr 'page-number', pgnum_label if pagenums_enabled
3588
- # QUESTION: should the fallback value be nil instead of empty string? or should we remove attribute if no value?
3589
- doc.set_attr 'part-title', ((part_info = parts_by_page[pgnum])[:title] || '')
3590
- if (part_numeral = part_info[:numeral])
3591
- doc.set_attr 'part-numeral', part_numeral
3592
- else
3593
- doc.remove_attr 'part-numeral'
3594
- end
3595
- doc.set_attr 'chapter-title', ((chap_info = chapters_by_page[pgnum])[:title] || '')
3596
- if (chap_numeral = chap_info[:numeral])
3597
- doc.set_attr 'chapter-numeral', chap_numeral
3598
- else
3599
- doc.remove_attr 'chapter-numeral'
3600
- end
3601
- doc.set_attr 'section-title', ((sect_info = sections_by_page[pgnum])[:title] || '')
3602
- doc.set_attr 'section-or-chapter-title', (sect_info[:title] || chap_info[:title] || '')
3603
-
3604
- stamp stamp_names[side] if stamp_names
3605
-
3606
- theme_font periphery do
3607
- canvas do
3608
- bounding_box [trim_styles[:content_left][side], trim_styles[:top][side]], width: trim_styles[:content_width][side], height: trim_styles[:height] do
3609
- if trim_styles[:column_rule_color] && (trim_column_rule_width = trim_styles[:column_rule_width]) > 0
3610
- trim_column_rule_spacing = trim_styles[:column_rule_spacing]
3611
- else
3612
- trim_column_rule_width = nil
3613
- end
3614
- prev_position = nil
3615
- ColumnPositions.each do |position|
3616
- next unless (content = content_by_position[position])
3617
- next unless (colspec = colspec_by_position[position])[:width] > 0
3618
- left, colwidth = colspec[:x], colspec[:width]
3619
- if trim_column_rule_width && colwidth < bounds.width
3620
- if (trim_column_rule = prev_position)
3621
- left += (trim_column_rule_spacing * 0.5)
3622
- colwidth -= trim_column_rule_spacing
3623
- else
3624
- colwidth -= (trim_column_rule_spacing * 0.5)
3625
- end
3626
- end
3627
- # FIXME: we need to have a content setting for chapter pages
3628
- if ::Array === content
3629
- redo_with_content = nil
3630
- # NOTE: float ensures cursor position is restored and returns us to current page if we overrun
3631
- float do
3632
- # NOTE: bounding_box is redundant if both vertical padding and border width are 0
3633
- bounding_box [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colwidth, height: trim_styles[:content_height][side] do
3634
- # NOTE: image vposition respects padding; use negative image_vertical_align value to revert
3635
- image_opts = content[1].merge position: colspec[:align], vposition: trim_styles[:img_valign]
3636
- begin
3637
- image_info = image content[0], image_opts
3638
- if (image_link = content[2])
3639
- image_info = { width: image_info.scaled_width, height: image_info.scaled_height } unless image_opts[:format] == 'svg'
3640
- add_link_to_image image_link, image_info, image_opts
3641
- end
3642
- rescue
3643
- redo_with_content = image_opts[:alt]
3644
- log :warn, %(could not embed image in running content: #{content[0]}; #{$!.message})
3645
- end
3646
- end
3647
- end
3648
- if redo_with_content
3649
- content_by_position[position] = redo_with_content
3650
- redo
3651
- end
3652
- else
3653
- theme_font %(#{periphery}_#{side}_#{position}) do
3654
- # NOTE: minor optimization
3655
- if content == '{page-number}'
3656
- content = pagenums_enabled ? pgnum_label : nil
3657
- else
3658
- content = apply_subs_discretely doc, content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
3659
- content = transform_text content, @text_transform if @text_transform
3660
- end
3661
- formatted_text_box (parse_text content, inline_format: [normalize: true]),
3662
- at: [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset] + ((Array trim_styles[:valign])[0] == :center ? font.descender * 0.5 : 0)],
3663
- color: @font_color,
3664
- width: colwidth,
3665
- height: trim_styles[:prose_content_height][side],
3666
- align: colspec[:align],
3667
- valign: trim_styles[:valign],
3668
- leading: trim_styles[:line_metrics].leading,
3669
- final_gap: false,
3670
- overflow: :truncate
3671
- end
3672
- end
3673
- bounding_box [colspec[:x], bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colspec[:width], height: trim_styles[:content_height][side] do
3674
- stroke_vertical_rule trim_styles[:column_rule_color], at: bounds.left, line_style: trim_styles[:column_rule_style], line_width: trim_column_rule_width
3675
- end if trim_column_rule
3676
- prev_position = position
3677
- end
3678
- end
3679
- end
3680
- end
3140
+ # NOTE: inline_format is true by default
3141
+ def ink_prose string, opts = {}
3142
+ top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || 0
3143
+ bot_margin = margin || (opts.delete :margin_bottom) || @theme.prose_margin_bottom
3144
+ if (transform = resolve_text_transform opts)
3145
+ string = transform_text string, transform
3681
3146
  end
3682
-
3683
- go_to_page prev_page_number
3684
- nil
3147
+ string = hyphenate_text string, @hyphenator if (opts.delete :hyphenate) && (defined? @hyphenator)
3148
+ # NOTE: used by extensions; ensures linked text gets formatted using the link styles
3149
+ if (anchor = opts.delete :anchor)
3150
+ string = anchor == true ? %(<a>#{string}</a>) : %(<a anchor="#{anchor}">#{string}</a>)
3151
+ end
3152
+ margin_top top_margin
3153
+ # NOTE: normalize makes endlines soft (replaces "\n" with ' ')
3154
+ inline_format_opts = { normalize: (opts.delete :normalize) != false }
3155
+ if (styles = opts.delete :styles)
3156
+ inline_format_opts[:inherited] = {
3157
+ styles: styles,
3158
+ text_decoration_color: (opts.delete :text_decoration_color),
3159
+ text_decoration_width: (opts.delete :text_decoration_width),
3160
+ }.compact
3161
+ end
3162
+ result = typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
3163
+ color: @font_color,
3164
+ inline_format: [inline_format_opts],
3165
+ align: @base_text_align.to_sym,
3166
+ }.merge(opts)
3167
+ margin_bottom bot_margin
3168
+ result
3685
3169
  end
3686
3170
 
3687
3171
  def allocate_running_content_layout doc, page, periphery, cache
@@ -3852,191 +3336,890 @@ module Asciidoctor
3852
3336
  end
3853
3337
  end
3854
3338
  end
3855
-
3856
- [trim_styles, colspec_dict, content_dict, stamp_names]
3339
+
3340
+ [trim_styles, colspec_dict, content_dict, stamp_names]
3341
+ end
3342
+ end
3343
+
3344
+ # TODO: delegate to ink_page_header and ink_page_footer per page
3345
+ def ink_running_content periphery, doc, skip = [1, 1], body_start_page_number = 1
3346
+ skip_pages, skip_pagenums = skip
3347
+ # NOTE: find and advance to first non-imported content page to use as model page
3348
+ return unless (content_start_page_number = state.pages[skip_pages..-1].index {|it| !it.imported_page? })
3349
+ content_start_page_number += (skip_pages + 1)
3350
+ num_pages = page_count
3351
+ prev_page_number = page_number
3352
+ go_to_page content_start_page_number
3353
+
3354
+ # FIXME: probably need to treat doctypes differently
3355
+ is_book = doc.doctype == 'book'
3356
+ header = doc.header? ? doc.header : nil
3357
+ sectlevels = (@theme[%(#{periphery}_sectlevels)] || 2).to_i
3358
+ sections = doc.find_by(context: :section) {|sect| sect.level <= sectlevels && sect != header }
3359
+ toc_title = (doc.attr 'toc-title').to_s if (toc_page_nums = @toc_extent&.page_range)
3360
+ disable_on_pages = @disable_running_content[periphery]
3361
+
3362
+ title_method = TitleStyles[@theme[%(#{periphery}_title_style)]]
3363
+ # FIXME: we need a proper model for all this page counting
3364
+ # FIXME: we make a big assumption that part & chapter start on new pages
3365
+ # index parts, chapters and sections by the physical page number on which they start
3366
+ part_start_pages = {}
3367
+ chapter_start_pages = {}
3368
+ section_start_pages = {}
3369
+ trailing_section_start_pages = {}
3370
+ sections.each do |sect|
3371
+ pgnum = (sect.attr 'pdf-page-start').to_i
3372
+ if is_book && ((sect_is_part = sect.sectname == 'part') || sect.level == 1)
3373
+ if sect_is_part
3374
+ part_start_pages[pgnum] ||= sect
3375
+ else
3376
+ chapter_start_pages[pgnum] ||= sect
3377
+ # FIXME: need a better way to indicate that part has ended
3378
+ part_start_pages[pgnum] = '' if sect.sectname == 'appendix' && !part_start_pages.empty?
3379
+ end
3380
+ else
3381
+ trailing_section_start_pages[pgnum] = sect
3382
+ section_start_pages[pgnum] ||= sect
3383
+ end
3384
+ end
3385
+
3386
+ # index parts, chapters, and sections by the physical page number on which they appear
3387
+ parts_by_page = SectionInfoByPage.new title_method
3388
+ chapters_by_page = SectionInfoByPage.new title_method
3389
+ sections_by_page = SectionInfoByPage.new title_method
3390
+ # QUESTION: should the default part be the doctitle?
3391
+ last_part = nil
3392
+ # QUESTION: should we enforce that the preamble is a preface?
3393
+ last_chap = is_book ? :pre : nil
3394
+ last_sect = nil
3395
+ sect_search_threshold = 1
3396
+ (1..num_pages).each do |pgnum|
3397
+ if (part = part_start_pages[pgnum])
3398
+ last_part = part
3399
+ last_chap = nil
3400
+ last_sect = nil
3401
+ end
3402
+ if (chap = chapter_start_pages[pgnum])
3403
+ last_chap = chap
3404
+ last_sect = nil
3405
+ end
3406
+ if (sect = section_start_pages[pgnum])
3407
+ last_sect = sect
3408
+ elsif part || chap
3409
+ sect_search_threshold = pgnum
3410
+ # NOTE: we didn't find a section on this page; look back to find last section started
3411
+ elsif last_sect
3412
+ (sect_search_threshold..(pgnum - 1)).reverse_each do |prev|
3413
+ if (sect = trailing_section_start_pages[prev])
3414
+ last_sect = sect
3415
+ break
3416
+ end
3417
+ end
3418
+ end
3419
+ parts_by_page[pgnum] = last_part
3420
+ if toc_page_nums&.cover? pgnum
3421
+ if is_book
3422
+ chapters_by_page[pgnum] = toc_title
3423
+ sections_by_page[pgnum] = nil
3424
+ else
3425
+ chapters_by_page[pgnum] = nil
3426
+ sections_by_page[pgnum] = section_start_pages[pgnum] || toc_title
3427
+ end
3428
+ toc_page_nums = nil if toc_page_nums.end == pgnum
3429
+ elsif last_chap == :pre
3430
+ chapters_by_page[pgnum] = pgnum < body_start_page_number ? doc.doctitle : (doc.attr 'preface-title', 'Preface')
3431
+ sections_by_page[pgnum] = last_sect
3432
+ else
3433
+ chapters_by_page[pgnum] = last_chap
3434
+ sections_by_page[pgnum] = last_sect
3435
+ end
3436
+ end
3437
+
3438
+ doctitle = resolve_doctitle doc, true
3439
+ # NOTE: set doctitle again so it's properly escaped
3440
+ doc.set_attr 'doctitle', doctitle.combined
3441
+ doc.set_attr 'document-title', doctitle.main
3442
+ doc.set_attr 'document-subtitle', doctitle.subtitle
3443
+ doc.set_attr 'page-count', (num_pages - skip_pagenums)
3444
+
3445
+ pagenums_enabled = doc.attr? 'pagenums'
3446
+ periphery_layout_cache = {}
3447
+ # NOTE: this block is invoked during PDF generation, after #write -> #render_file and thus after #convert_document
3448
+ repeat (content_start_page_number..num_pages), dynamic: true do
3449
+ pgnum = page_number
3450
+ # NOTE: don't write on pages which are imported / inserts (otherwise we can get a corrupt PDF)
3451
+ next if page.imported_page? || (disable_on_pages.include? pgnum)
3452
+ virtual_pgnum = pgnum - skip_pagenums
3453
+ pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new pgnum, :lower) : virtual_pgnum).to_s
3454
+ side = page_side((@folio_placement[:basis] == :physical ? pgnum : virtual_pgnum), @folio_placement[:inverted])
3455
+ doc.set_attr 'page-layout', page.layout.to_s
3456
+
3457
+ # NOTE: running content is cached per page layout
3458
+ # QUESTION: should allocation be per side?
3459
+ trim_styles, colspec_dict, content_dict, stamp_names = allocate_running_content_layout doc, page, periphery, periphery_layout_cache
3460
+ # FIXME: we need to have a content setting for chapter pages
3461
+ content_by_position, colspec_by_position = content_dict[side], colspec_dict[side]
3462
+
3463
+ doc.set_attr 'page-number', pgnum_label if pagenums_enabled
3464
+ # QUESTION: should the fallback value be nil instead of empty string? or should we remove attribute if no value?
3465
+ doc.set_attr 'part-title', ((part_info = parts_by_page[pgnum])[:title] || '')
3466
+ if (part_numeral = part_info[:numeral])
3467
+ doc.set_attr 'part-numeral', part_numeral
3468
+ else
3469
+ doc.remove_attr 'part-numeral'
3470
+ end
3471
+ doc.set_attr 'chapter-title', ((chap_info = chapters_by_page[pgnum])[:title] || '')
3472
+ if (chap_numeral = chap_info[:numeral])
3473
+ doc.set_attr 'chapter-numeral', chap_numeral
3474
+ else
3475
+ doc.remove_attr 'chapter-numeral'
3476
+ end
3477
+ doc.set_attr 'section-title', ((sect_info = sections_by_page[pgnum])[:title] || '')
3478
+ doc.set_attr 'section-or-chapter-title', (sect_info[:title] || chap_info[:title] || '')
3479
+
3480
+ stamp stamp_names[side] if stamp_names
3481
+
3482
+ theme_font periphery do
3483
+ canvas do
3484
+ bounding_box [trim_styles[:content_left][side], trim_styles[:top][side]], width: trim_styles[:content_width][side], height: trim_styles[:height] do
3485
+ if trim_styles[:column_rule_color] && (trim_column_rule_width = trim_styles[:column_rule_width]) > 0
3486
+ trim_column_rule_spacing = trim_styles[:column_rule_spacing]
3487
+ else
3488
+ trim_column_rule_width = nil
3489
+ end
3490
+ prev_position = nil
3491
+ ColumnPositions.each do |position|
3492
+ next unless (content = content_by_position[position])
3493
+ next unless (colspec = colspec_by_position[position])[:width] > 0
3494
+ left, colwidth = colspec[:x], colspec[:width]
3495
+ if trim_column_rule_width && colwidth < bounds.width
3496
+ if (trim_column_rule = prev_position)
3497
+ left += (trim_column_rule_spacing * 0.5)
3498
+ colwidth -= trim_column_rule_spacing
3499
+ else
3500
+ colwidth -= (trim_column_rule_spacing * 0.5)
3501
+ end
3502
+ end
3503
+ # FIXME: we need to have a content setting for chapter pages
3504
+ if ::Array === content
3505
+ redo_with_content = nil
3506
+ # NOTE: float ensures cursor position is restored and returns us to current page if we overrun
3507
+ float do
3508
+ # NOTE: bounding_box is redundant if both vertical padding and border width are 0
3509
+ bounding_box [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colwidth, height: trim_styles[:content_height][side] do
3510
+ # NOTE: image vposition respects padding; use negative image_vertical_align value to revert
3511
+ image_opts = content[1].merge position: colspec[:align], vposition: trim_styles[:img_valign]
3512
+ begin
3513
+ image_info = image content[0], image_opts
3514
+ if (image_link = content[2])
3515
+ image_info = { width: image_info.scaled_width, height: image_info.scaled_height } unless image_opts[:format] == 'svg'
3516
+ add_link_to_image image_link, image_info, image_opts
3517
+ end
3518
+ rescue
3519
+ redo_with_content = image_opts[:alt]
3520
+ log :warn, %(could not embed image in running content: #{content[0]}; #{$!.message})
3521
+ end
3522
+ end
3523
+ end
3524
+ if redo_with_content
3525
+ content_by_position[position] = redo_with_content
3526
+ redo
3527
+ end
3528
+ else
3529
+ theme_font %(#{periphery}_#{side}_#{position}) do
3530
+ # NOTE: minor optimization
3531
+ if content == '{page-number}'
3532
+ content = pagenums_enabled ? pgnum_label : nil
3533
+ else
3534
+ content = apply_subs_discretely doc, content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
3535
+ content = transform_text content, @text_transform if @text_transform
3536
+ end
3537
+ formatted_text_box (parse_text content, inline_format: [normalize: true]),
3538
+ at: [left, bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset] + ((Array trim_styles[:valign])[0] == :center ? font.descender * 0.5 : 0)],
3539
+ color: @font_color,
3540
+ width: colwidth,
3541
+ height: trim_styles[:prose_content_height][side],
3542
+ align: colspec[:align],
3543
+ valign: trim_styles[:valign],
3544
+ leading: trim_styles[:line_metrics].leading,
3545
+ final_gap: false,
3546
+ overflow: :truncate
3547
+ end
3548
+ end
3549
+ bounding_box [colspec[:x], bounds.top - trim_styles[:padding][side][0] - trim_styles[:content_offset]], width: colspec[:width], height: trim_styles[:content_height][side] do
3550
+ stroke_vertical_rule trim_styles[:column_rule_color], at: bounds.left, line_style: trim_styles[:column_rule_style], line_width: trim_column_rule_width
3551
+ end if trim_column_rule
3552
+ prev_position = position
3553
+ end
3554
+ end
3555
+ end
3556
+ end
3557
+ end
3558
+
3559
+ go_to_page prev_page_number
3560
+ nil
3561
+ end
3562
+
3563
+ def ink_title_page doc
3564
+ # QUESTION: allow alignment per element on title page?
3565
+ title_text_align = (@theme.title_page_text_align || @base_text_align).to_sym
3566
+
3567
+ if @theme.title_page_logo_display != 'none' && (logo_image_path = (doc.attr 'title-logo-image') || (logo_image_from_theme = @theme.title_page_logo_image))
3568
+ if (logo_image_path.include? ':') && logo_image_path =~ ImageAttributeValueRx
3569
+ logo_image_attrs = (AttributeList.new $2).parse %w(alt width height)
3570
+ if logo_image_from_theme
3571
+ relative_to_imagesdir = false
3572
+ logo_image_path = apply_subs_discretely doc, $1, subs: [:attributes]
3573
+ logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
3574
+ else
3575
+ relative_to_imagesdir = true
3576
+ logo_image_path = $1
3577
+ end
3578
+ else
3579
+ logo_image_attrs = {}
3580
+ relative_to_imagesdir = false
3581
+ if logo_image_from_theme
3582
+ logo_image_path = apply_subs_discretely doc, logo_image_path, subs: [:attributes]
3583
+ logo_image_path = ThemeLoader.resolve_theme_asset logo_image_path, @themesdir unless doc.is_uri? logo_image_path
3584
+ end
3585
+ end
3586
+ if (::Asciidoctor::Image.target_and_format logo_image_path)[1] == 'pdf'
3587
+ log :error, %(PDF format not supported for title page logo image: #{logo_image_path})
3588
+ else
3589
+ logo_image_attrs['target'] = logo_image_path
3590
+ # NOTE: at the very least, title_text_align will be a valid alignment value
3591
+ logo_image_attrs['align'] = [(logo_image_attrs.delete 'align'), @theme.title_page_logo_align, title_text_align.to_s].find {|val| (BlockAlignmentNames.include? val) }
3592
+ if (logo_image_top = logo_image_attrs['top'] || @theme.title_page_logo_top)
3593
+ initial_y, @y = @y, (resolve_top logo_image_top)
3594
+ end
3595
+ # NOTE: pinned option keeps image on same page
3596
+ indent (@theme.title_page_logo_margin_left || 0), (@theme.title_page_logo_margin_right || 0) do
3597
+ # FIXME: add API to Asciidoctor for creating blocks outside of extensions
3598
+ convert_image (::Asciidoctor::Block.new doc, :image, content_model: :empty, attributes: logo_image_attrs), relative_to_imagesdir: relative_to_imagesdir, pinned: true
3599
+ end
3600
+ @y = initial_y if initial_y
3601
+ end
3602
+ end
3603
+
3604
+ # TODO: prevent content from spilling to next page
3605
+ theme_font :title_page do
3606
+ if (title_top = @theme.title_page_title_top)
3607
+ @y = resolve_top title_top
3608
+ end
3609
+ unless @theme.title_page_title_display == 'none'
3610
+ doctitle = doc.doctitle partition: true
3611
+ move_down @theme.title_page_title_margin_top || 0
3612
+ indent (@theme.title_page_title_margin_left || 0), (@theme.title_page_title_margin_right || 0) do
3613
+ theme_font :title_page_title do
3614
+ ink_prose doctitle.main, align: title_text_align, margin: 0
3615
+ end
3616
+ end
3617
+ move_down @theme.title_page_title_margin_bottom || 0
3618
+ end
3619
+ if @theme.title_page_subtitle_display != 'none' && (subtitle = (doctitle || (doc.doctitle partition: true)).subtitle)
3620
+ move_down @theme.title_page_subtitle_margin_top || 0
3621
+ indent (@theme.title_page_subtitle_margin_left || 0), (@theme.title_page_subtitle_margin_right || 0) do
3622
+ theme_font :title_page_subtitle do
3623
+ ink_prose subtitle, align: title_text_align, margin: 0
3624
+ end
3625
+ end
3626
+ move_down @theme.title_page_subtitle_margin_bottom || 0
3627
+ end
3628
+ if @theme.title_page_authors_display != 'none' && (doc.attr? 'authors')
3629
+ move_down @theme.title_page_authors_margin_top || 0
3630
+ indent (@theme.title_page_authors_margin_left || 0), (@theme.title_page_authors_margin_right || 0) do
3631
+ generic_authors_content = @theme.title_page_authors_content
3632
+ authors_content = {
3633
+ name_only: @theme.title_page_authors_content_name_only || generic_authors_content,
3634
+ with_email: @theme.title_page_authors_content_with_email || generic_authors_content,
3635
+ with_url: @theme.title_page_authors_content_with_url || generic_authors_content,
3636
+ }
3637
+ authors = doc.authors.map.with_index do |author, idx|
3638
+ with_author doc, author, idx == 0 do
3639
+ author_content_key = (url = doc.attr 'url') ? ((url.start_with? 'mailto:') ? :with_email : :with_url) : :name_only
3640
+ if (author_content = authors_content[author_content_key])
3641
+ apply_subs_discretely doc, author_content, drop_lines_with_unresolved_attributes: true, imagesdir: @themesdir
3642
+ else
3643
+ doc.attr 'author'
3644
+ end
3645
+ end
3646
+ end.join @theme.title_page_authors_delimiter
3647
+ theme_font :title_page_authors do
3648
+ ink_prose authors, align: title_text_align, margin: 0, normalize: true
3649
+ end
3650
+ end
3651
+ move_down @theme.title_page_authors_margin_bottom || 0
3652
+ end
3653
+ unless @theme.title_page_revision_display == 'none' || (revision_info = [(doc.attr? 'revnumber') ? %(#{doc.attr 'version-label'} #{doc.attr 'revnumber'}) : nil, (doc.attr 'revdate')].compact).empty?
3654
+ move_down @theme.title_page_revision_margin_top || 0
3655
+ revision_text = revision_info.join @theme.title_page_revision_delimiter
3656
+ if (revremark = doc.attr 'revremark')
3657
+ revision_text = %(#{revision_text}: #{revremark})
3658
+ end
3659
+ indent (@theme.title_page_revision_margin_left || 0), (@theme.title_page_revision_margin_right || 0) do
3660
+ theme_font :title_page_revision do
3661
+ ink_prose revision_text, align: title_text_align, margin: 0, normalize: false
3662
+ end
3663
+ end
3664
+ move_down @theme.title_page_revision_margin_bottom || 0
3665
+ end
3666
+ end
3667
+ end
3668
+
3669
+ def allocate_toc doc, toc_num_levels, toc_start_cursor, title_page_on
3670
+ toc_start_page_number = page_number
3671
+ to_page = nil
3672
+ extent = dry_run onto: self do
3673
+ to_page = (ink_toc doc, toc_num_levels, toc_start_page_number, toc_start_cursor).end
3674
+ theme_margin :block, :bottom unless title_page_on
3675
+ end
3676
+ # NOTE: patch for custom converters that allocate extra TOC pages without actually creating them
3677
+ if to_page > extent.to.page
3678
+ extent.to.page = to_page
3679
+ extent.to.cursor = bounds.height
3680
+ end
3681
+ # NOTE: reserve pages for the toc; leaves cursor on page after last page in toc
3682
+ if title_page_on
3683
+ extent.each_page { start_new_page }
3684
+ else
3685
+ extent.each_page {|first_page| start_new_page unless first_page }
3686
+ move_cursor_to extent.to.cursor
3687
+ end
3688
+ extent
3689
+ end
3690
+
3691
+ def get_entries_for_toc node
3692
+ node.sections
3693
+ end
3694
+
3695
+ # NOTE: num_front_matter_pages not used during a dry run
3696
+ def ink_toc doc, num_levels, toc_page_number, start_cursor, num_front_matter_pages = 0
3697
+ go_to_page toc_page_number unless (page_number == toc_page_number) || scratch?
3698
+ start_page_number = page_number
3699
+ move_cursor_to start_cursor
3700
+ unless (toc_title = doc.attr 'toc-title').nil_or_empty?
3701
+ theme_font_cascade [[:heading, level: 2], :toc_title] do
3702
+ toc_title_text_align = (@theme.toc_title_text_align || @theme.heading_h2_text_align || @theme.heading_text_align || @base_text_align).to_sym
3703
+ ink_general_heading doc, toc_title, align: toc_title_text_align, level: 2, outdent: true, role: :toctitle
3704
+ end
3705
+ end
3706
+ # QUESTION: should we skip this whole method if num_levels < 0?
3707
+ unless num_levels < 0
3708
+ dot_leader = theme_font :toc do
3709
+ # TODO: we could simplify by using nested theme_font :toc_dot_leader
3710
+ if (dot_leader_font_style = @theme.toc_dot_leader_font_style&.to_sym || :normal) != font_style
3711
+ font_style dot_leader_font_style
3712
+ end
3713
+ font_size @theme.toc_dot_leader_font_size
3714
+ {
3715
+ font_color: @theme.toc_dot_leader_font_color || @font_color,
3716
+ font_style: dot_leader_font_style,
3717
+ font_size: font_size,
3718
+ levels: ((dot_leader_l = @theme.toc_dot_leader_levels) == 'none' ? ::Set.new :
3719
+ (dot_leader_l && dot_leader_l != 'all' ? dot_leader_l.to_s.split.map(&:to_i).to_set : (0..num_levels).to_set)),
3720
+ text: (dot_leader_text = @theme.toc_dot_leader_content || DotLeaderTextDefault),
3721
+ width: dot_leader_text.empty? ? 0 : (rendered_width_of_string dot_leader_text),
3722
+ # TODO: spacer gives a little bit of room between dots and page number
3723
+ spacer: { text: NoBreakSpace, size: (spacer_font_size = @font_size * 0.25) },
3724
+ spacer_width: (rendered_width_of_char NoBreakSpace, size: spacer_font_size),
3725
+ }
3726
+ end
3727
+ theme_margin :toc, :top
3728
+ ink_toc_level (get_entries_for_toc doc), num_levels, dot_leader, num_front_matter_pages
3729
+ end
3730
+ # NOTE: range must be calculated relative to toc_page_number; absolute page number in scratch document is arbitrary
3731
+ toc_page_numbers = (toc_page_number..(toc_page_number + (page_number - start_page_number)))
3732
+ go_to_page page_count unless scratch?
3733
+ toc_page_numbers
3734
+ end
3735
+
3736
+ def ink_toc_level entries, num_levels, dot_leader, num_front_matter_pages
3737
+ # NOTE: font options aren't always reliable, so store size separately
3738
+ toc_font_info = theme_font :toc do
3739
+ { font: font, size: @font_size }
3740
+ end
3741
+ hanging_indent = @theme.toc_hanging_indent
3742
+ entries.each do |entry|
3743
+ next if (num_levels_for_entry = (entry.attr 'toclevels', num_levels).to_i) < (entry_level = entry.level + 1).pred ||
3744
+ !(entry_anchor = (entry.attr 'pdf-anchor') || entry.id) ||
3745
+ ((entry.option? 'notitle') && entry == entry.document.last_child && entry.empty?)
3746
+ theme_font :toc, level: entry_level do
3747
+ entry_title = entry.context == :section ? entry.numbered_title : (entry.title? ? entry.title : (entry.xreftext 'basic'))
3748
+ next if entry_title.empty?
3749
+ entry_title = transform_text entry_title, @text_transform if @text_transform
3750
+ pgnum_label_placeholder_width = rendered_width_of_string '0' * @toc_max_pagenum_digits
3751
+ # NOTE: only write title (excluding dots and page number) if this is a dry run
3752
+ if scratch?
3753
+ indent 0, pgnum_label_placeholder_width do
3754
+ # NOTE: must wrap title in empty anchor element in case links are styled with different font family / size
3755
+ ink_prose entry_title, anchor: true, normalize: false, hanging_indent: hanging_indent, normalize_line_height: true, margin: 0
3756
+ end
3757
+ else
3758
+ if !(physical_pgnum = entry.attr 'pdf-page-start') &&
3759
+ (target_page_ref = (get_dest entry_anchor)&.first) &&
3760
+ (target_page_idx = state.pages.index {|candidate| candidate.dictionary == target_page_ref })
3761
+ physical_pgnum = target_page_idx + 1
3762
+ end
3763
+ if physical_pgnum
3764
+ virtual_pgnum = physical_pgnum - num_front_matter_pages
3765
+ pgnum_label = (virtual_pgnum < 1 ? (RomanNumeral.new physical_pgnum, :lower) : virtual_pgnum).to_s
3766
+ else
3767
+ pgnum_label = '?'
3768
+ end
3769
+ start_page_number = page_number
3770
+ start_cursor = cursor
3771
+ start_dots = nil
3772
+ entry_title_inherited = (apply_text_decoration ::Set.new, :toc, entry_level).merge anchor: entry_anchor, color: @font_color
3773
+ # NOTE: use text formatter to add anchor overlay to avoid using inline format with synthetic anchor tag
3774
+ entry_title_fragments = text_formatter.format entry_title, inherited: entry_title_inherited
3775
+ line_metrics = calc_line_metrics @base_line_height
3776
+ indent 0, pgnum_label_placeholder_width do
3777
+ (entry_title_fragments[-1][:callback] ||= []) << (last_fragment_pos = ::Asciidoctor::PDF::FormattedText::FragmentPositionRenderer.new)
3778
+ typeset_formatted_text entry_title_fragments, line_metrics, hanging_indent: hanging_indent, normalize_line_height: true
3779
+ start_dots = last_fragment_pos.right + hanging_indent
3780
+ last_fragment_cursor = last_fragment_pos.top + line_metrics.padding_top
3781
+ start_cursor = last_fragment_cursor if last_fragment_pos.page_number > start_page_number || (start_cursor - last_fragment_cursor) > line_metrics.height
3782
+ end
3783
+ end_cursor = cursor
3784
+ move_cursor_to start_cursor
3785
+ # NOTE: we're guaranteed to be on the same page as the final line of the entry
3786
+ if dot_leader[:width] > 0 && (dot_leader[:levels].include? entry_level.pred)
3787
+ pgnum_label_width = rendered_width_of_string pgnum_label
3788
+ pgnum_label_font_settings = { color: @font_color, font: font_family, size: @font_size, styles: font_styles }
3789
+ save_font do
3790
+ # NOTE: the same font is used for dot leaders throughout toc
3791
+ set_font toc_font_info[:font], dot_leader[:font_size]
3792
+ font_style dot_leader[:font_style]
3793
+ num_dots = [((bounds.width - start_dots - dot_leader[:spacer_width] - pgnum_label_width) / dot_leader[:width]).floor, 0].max
3794
+ # FIXME: dots don't line up in columns if width of page numbers differ
3795
+ typeset_formatted_text [
3796
+ { text: dot_leader[:text] * num_dots, color: dot_leader[:font_color] },
3797
+ dot_leader[:spacer],
3798
+ ({ text: pgnum_label, anchor: entry_anchor }.merge pgnum_label_font_settings),
3799
+ ], line_metrics, align: :right
3800
+ end
3801
+ else
3802
+ typeset_formatted_text [{ text: pgnum_label, color: @font_color, anchor: entry_anchor }], line_metrics, align: :right
3803
+ end
3804
+ move_cursor_to end_cursor
3805
+ end
3806
+ end
3807
+ indent @theme.toc_indent do
3808
+ ink_toc_level (get_entries_for_toc entry), num_levels_for_entry, dot_leader, num_front_matter_pages
3809
+ end if num_levels_for_entry >= entry_level
3810
+ end
3811
+ end
3812
+
3813
+ # Sends the specified message to the log unless this method is called from the scratch document
3814
+ def log severity, message = nil, &block
3815
+ logger.send severity, message, &block unless scratch?
3816
+ end
3817
+
3818
+ # Insert a margin at the specified side if the cursor is not at the top of
3819
+ # the page. Start a new page if amount is greater than the remaining space on
3820
+ # the page.
3821
+ def margin amount, _side
3822
+ if (amount || 0) == 0 || at_page_top?
3823
+ 0
3824
+ elsif cursor > amount
3825
+ move_down amount
3826
+ amount
3827
+ else
3828
+ # move cursor to top of next page
3829
+ bounds.move_past_bottom
3830
+ 0
3831
+ end
3832
+ end
3833
+
3834
+ # Insert a bottom margin equal to amount unless cursor is at the top of the
3835
+ # page (not likely). Start a new page instead if amount is greater than the
3836
+ # remaining space on the page.
3837
+ def margin_bottom amount
3838
+ margin amount, :bottom
3839
+ end
3840
+
3841
+ # Insert a top margin equal to amount if cursor is not at the top of the
3842
+ # page. Start a new page instead if amount is greater than the remaining
3843
+ # space on the page.
3844
+ def margin_top amount
3845
+ margin amount, :top
3846
+ end
3847
+
3848
+ def next_enclosed_block block, descend: false
3849
+ return if (context = block.context) == :document
3850
+ parent_context = (parent = block.parent).context
3851
+ if (list_item = context == :list_item)
3852
+ return block.first_child if descend && block.blocks?
3853
+ siblings = parent.items
3854
+ else
3855
+ siblings = parent.blocks
3856
+ end
3857
+ siblings = siblings.flatten if parent_context == :dlist
3858
+ if block != siblings[-1]
3859
+ (self_idx = siblings.index block) && siblings[self_idx + 1]
3860
+ elsif parent_context == :list_item || (parent_context == :open && parent.style != 'abstract') || parent_context == :section
3861
+ next_enclosed_block parent
3862
+ elsif list_item && (grandparent = parent.parent).context == :list_item
3863
+ next_enclosed_block grandparent
3864
+ end
3865
+ end
3866
+
3867
+ def register_fonts font_catalog, fonts_dir
3868
+ return unless font_catalog
3869
+ dirs = (fonts_dir.split ValueSeparatorRx, -1).map do |dir|
3870
+ dir == 'GEM_FONTS_DIR' || dir.empty? ? ThemeLoader::FontsDir : dir
3871
+ end
3872
+ font_catalog.each do |key, styles|
3873
+ styles = styles.each_with_object({}) do |(style, path), accum|
3874
+ found = dirs.any? do |dir|
3875
+ resolved_font_path = font_path path, dir
3876
+ accum[style.to_sym] = resolved_font_path if ::File.readable? resolved_font_path
3877
+ end
3878
+ raise ::Errno::ENOENT, ((File.absolute_path? path) ? %(#{path} not found) : %(#{path} not found in #{fonts_dir.gsub ValueSeparatorRx, ' or '})) unless found
3879
+ end
3880
+ register_font key => styles
3881
+ end
3882
+ end
3883
+
3884
+ # Compute the rendered width of a char, taking fallback fonts into account
3885
+ def rendered_width_of_char char, opts = {}
3886
+ unless @fallback_fonts.empty? || (font.glyph_present? char)
3887
+ @fallback_fonts.each do |fallback_font|
3888
+ font fallback_font do
3889
+ return width_of_string char, opts if font.glyph_present? char
3890
+ end
3891
+ end
3892
+ end
3893
+ width_of_string char, opts
3894
+ end
3895
+
3896
+ # Compute the rendered width of a string, taking fallback fonts into account
3897
+ def rendered_width_of_string str, opts = {}
3898
+ opts = opts.merge kerning: default_kerning?
3899
+ if str.length == 1
3900
+ rendered_width_of_char str, opts
3901
+ elsif (chars = str.each_char).all? {|char| font.glyph_present? char }
3902
+ width_of_string str, opts
3903
+ else
3904
+ char_widths = chars.map {|char| rendered_width_of_char char, opts }
3905
+ char_widths.sum + (char_widths.length * character_spacing)
3906
+ end
3907
+ end
3908
+
3909
+ # Resolve the path and sizing of the background image either from a document attribute or theme key.
3910
+ #
3911
+ # Returns the argument list for the image method if the document attribute or theme key is found. Otherwise,
3912
+ # nothing. The first argument in the argument list is the image path. If that value is nil, the background
3913
+ # image is disabled. The second argument is the options hash to specify the dimensions, such as width and fit.
3914
+ def resolve_background_image doc, theme, key, opts = {}
3915
+ if ::String === key
3916
+ theme_key = opts.delete :theme_key
3917
+ image_path = (doc.attr key) || (from_theme = theme[theme_key || (key.tr '-', '_').to_sym])
3918
+ else
3919
+ image_path = from_theme = theme[key]
3920
+ end
3921
+ symbolic_paths = opts.delete :symbolic_paths
3922
+ if image_path
3923
+ if symbolic_paths&.include? image_path
3924
+ return [image_path, {}]
3925
+ elsif image_path == 'none'
3926
+ return []
3927
+ elsif (image_path.include? ':') && image_path =~ ImageAttributeValueRx
3928
+ image_attrs = (AttributeList.new $2).parse %w(alt width)
3929
+ if from_theme
3930
+ image_path = apply_subs_discretely doc, $1, subs: [:attributes]
3931
+ image_relative_to = @themesdir
3932
+ else
3933
+ image_path = $1
3934
+ image_relative_to = true
3935
+ end
3936
+ elsif from_theme
3937
+ image_path = apply_subs_discretely doc, image_path, subs: [:attributes]
3938
+ image_relative_to = @themesdir
3939
+ end
3940
+
3941
+ image_path, image_format = ::Asciidoctor::Image.target_and_format image_path, image_attrs
3942
+ image_path = resolve_image_path doc, image_path, image_format, image_relative_to
3943
+
3944
+ return unless image_path
3945
+
3946
+ unless ::File.readable? image_path
3947
+ log :warn, %(#{key.to_s.tr '-_', ' '} not found or readable: #{image_path})
3948
+ return
3949
+ end
3950
+
3951
+ if image_format == 'pdf'
3952
+ [image_path, page: [((image_attrs || {})['page']).to_i, 1].max, format: image_format]
3953
+ else
3954
+ [image_path, (resolve_image_options image_path, image_format, image_attrs, (({ background: true, container_size: [page_width, page_height] }.merge opts)))]
3955
+ end
3956
+ end
3957
+ end
3958
+
3959
+ def resolve_doctitle doc, partition = nil
3960
+ if doc.header?
3961
+ doc.doctitle partition: partition
3962
+ elsif partition
3963
+ ::Asciidoctor::Document::Title.new (doc.attr 'untitled-label'), separator: (doc.attr 'title-separator')
3964
+ else
3965
+ doc.attr 'untitled-label'
3966
+ end
3967
+ end
3968
+
3969
+ # Resolves the explicit width, if specified, as a PDF pt value.
3970
+ #
3971
+ # Resolves the explicit width, first considering the pdfwidth attribute, then the scaledwidth
3972
+ # attribute, then the theme default (if enabled by the :use_fallback option), and finally the
3973
+ # width attribute. If the specified value is in pixels, the value is scaled by 75% to perform
3974
+ # approximate CSS px to PDF pt conversion. If the value is a percentage, and the
3975
+ # bounds_width option is given, the percentage of the bounds_width value is returned.
3976
+ # Otherwise, the percentage width is returned.
3977
+ #--
3978
+ # QUESTION: should we enforce positive result?
3979
+ def resolve_explicit_width attrs, opts = {}
3980
+ bounds_width = opts[:bounds_width]
3981
+ # QUESTION: should we restrict width to bounds_width for pdfwidth?
3982
+ if attrs.key? 'pdfwidth'
3983
+ if (width = attrs['pdfwidth']).end_with? '%'
3984
+ bounds_width ? (width.to_f / 100) * bounds_width : width
3985
+ elsif opts[:support_vw] && (width.end_with? 'vw')
3986
+ (width.chomp 'vw').extend ViewportWidth
3987
+ else
3988
+ str_to_pt width
3989
+ end
3990
+ elsif attrs.key? 'scaledwidth'
3991
+ # NOTE: the parser automatically appends % if value is unitless
3992
+ if (width = attrs['scaledwidth']).end_with? '%'
3993
+ bounds_width ? (width.to_f / 100) * bounds_width : width
3994
+ else
3995
+ str_to_pt width
3996
+ end
3997
+ elsif opts[:use_fallback] && (width = @theme.image_width)
3998
+ if ::Numeric === width
3999
+ width
4000
+ elsif (width = width.to_s).end_with? '%'
4001
+ bounds_width ? (width.to_f / 100) * bounds_width : bounds_width
4002
+ elsif opts[:support_vw] && (width.end_with? 'vw')
4003
+ (width.chomp 'vw').extend ViewportWidth
4004
+ else
4005
+ str_to_pt width
4006
+ end
4007
+ elsif attrs.key? 'width'
4008
+ if (width = attrs['width']).end_with? '%'
4009
+ width = (width.to_f / 100) * bounds_width if bounds_width
4010
+ elsif DigitsRx.match? width
4011
+ width = to_pt width.to_f, :px
4012
+ else
4013
+ return
4014
+ end
4015
+ bounds_width && opts[:constrain_to_bounds] ? [bounds_width, width].min : width
4016
+ end
4017
+ end
4018
+
4019
+ def resolve_image_options image_path, image_format, image_attrs, opts = {}
4020
+ if image_format == 'svg'
4021
+ image_opts = {
4022
+ enable_file_requests_with_root: (::File.dirname image_path),
4023
+ enable_web_requests: allow_uri_read ? (method :load_open_uri).to_proc : false,
4024
+ cache_images: cache_uri,
4025
+ fallback_font_name: fallback_svg_font_name,
4026
+ format: 'svg',
4027
+ }
4028
+ else
4029
+ image_opts = {}
4030
+ end
4031
+ container_size = opts[:container_size]
4032
+ if image_attrs
4033
+ if (alt = image_attrs['alt'])
4034
+ image_opts[:alt] = %([#{alt}])
4035
+ end
4036
+ if (background = opts[:background]) && (image_pos = image_attrs['position']) && (image_pos = resolve_background_position image_pos, nil)
4037
+ image_opts.update image_pos
4038
+ end
4039
+ if (image_fit = image_attrs['fit'] || (background ? 'contain' : nil))
4040
+ image_fit = 'contain' if image_format == 'svg' && image_fit == 'fill'
4041
+ container_width, container_height = container_size
4042
+ case image_fit
4043
+ when 'none'
4044
+ if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4045
+ image_opts[:width] = image_width
4046
+ end
4047
+ when 'scale-down'
4048
+ # NOTE: if width and height aren't set in SVG, real width and height are computed after stretching viewbox to fit page
4049
+ if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4050
+ if image_width > container_width
4051
+ image_opts[:fit] = container_size
4052
+ else
4053
+ image_size = intrinsic_image_dimensions image_path, image_format
4054
+ if image_width * (image_size[:height].to_f / image_size[:width]) > container_height
4055
+ image_opts[:fit] = container_size
4056
+ else
4057
+ image_opts[:width] = image_width
4058
+ end
4059
+ end
4060
+ else
4061
+ image_size = intrinsic_image_dimensions image_path, image_format
4062
+ image_opts[:fit] = container_size if image_size[:width] > container_width || image_size[:height] > container_height
4063
+ end
4064
+ when 'cover'
4065
+ # QUESTION: should we take explicit width into account?
4066
+ image_size = intrinsic_image_dimensions image_path, image_format
4067
+ if container_width * (image_size[:height].to_f / image_size[:width]) < container_height
4068
+ image_opts[:height] = container_height
4069
+ else
4070
+ image_opts[:width] = container_width
4071
+ end
4072
+ when 'fill'
4073
+ image_opts[:width] = container_width
4074
+ image_opts[:height] = container_height
4075
+ else # 'contain'
4076
+ image_opts[:fit] = container_size
4077
+ end
4078
+ elsif (image_width = resolve_explicit_width image_attrs, bounds_width: container_size[0])
4079
+ image_opts[:width] = image_width
4080
+ else # default to fit=contain if sizing is not specified
4081
+ image_opts[:fit] = container_size
4082
+ end
4083
+ else
4084
+ image_opts[:fit] = container_size
3857
4085
  end
4086
+ image_opts
3858
4087
  end
3859
4088
 
3860
- def add_outline doc, num_levels, toc_page_nums, num_front_matter_pages, has_front_cover
3861
- if ::String === num_levels
3862
- if num_levels.include? ':'
3863
- num_levels, expand_levels = num_levels.split ':', 2
3864
- num_levels = num_levels.empty? ? (doc.attr 'toclevels', 2).to_i : num_levels.to_i
3865
- expand_levels = expand_levels.to_i
3866
- else
3867
- num_levels = expand_levels = num_levels.to_i
3868
- end
4089
+ # Resolve the system path of the specified image path.
4090
+ #
4091
+ # Resolve and normalize the absolute system path of the specified image,
4092
+ # taking into account the imagesdir attribute. If an image path is not
4093
+ # specified, the path is read from the target attribute of the specified
4094
+ # document node.
4095
+ #
4096
+ # If the target is a URI and the allow-uri-read attribute is set on the
4097
+ # document, read the file contents to a temporary file and return the path to
4098
+ # the temporary file. If the target is a URI and the allow-uri-read attribute
4099
+ # is not set, or the URI cannot be read, this method returns a nil value.
4100
+ #
4101
+ # When a temporary file is used, the file is stored in @tmp_files to be cleaned up after conversion.
4102
+ def resolve_image_path node, image_path, image_format, relative_to = true
4103
+ doc = node.document
4104
+ if relative_to == true
4105
+ imagesdir = nil if (imagesdir = doc.attr 'imagesdir').nil_or_empty? || imagesdir == '.' || imagesdir == './'
3869
4106
  else
3870
- expand_levels = num_levels
3871
- end
3872
- front_matter_counter = RomanNumeral.new 0, :lower
3873
- pagenum_labels = {}
3874
-
3875
- num_front_matter_pages.times do |n|
3876
- pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new front_matter_counter.next!.to_s) }
3877
- end
3878
-
3879
- # add labels for each content page, which is required for reader's page navigator to work correctly
3880
- (num_front_matter_pages..(page_count - 1)).each_with_index do |n, i|
3881
- pagenum_labels[n] = { P: (::PDF::Core::LiteralString.new (i + 1).to_s) }
3882
- end
3883
-
3884
- unless toc_page_nums.none? || (toc_title = doc.attr 'toc-title').nil_or_empty?
3885
- toc_section = insert_toc_section doc, toc_title, toc_page_nums
4107
+ imagesdir = relative_to
3886
4108
  end
3887
-
3888
- outline.define do
3889
- initial_pagenum = has_front_cover ? 2 : 1
3890
- # FIXME: use sanitize: :plain_text on Document#doctitle once available
3891
- if document.page_count >= initial_pagenum && (outline_title = doc.attr 'outline-title') &&
3892
- (outline_title.empty? ? (outline_title = document.resolve_doctitle doc) : outline_title)
3893
- page title: (document.sanitize outline_title), destination: (document.dest_top initial_pagenum)
3894
- end
3895
- # QUESTION: is there any way to get add_outline_level to invoke in the context of the outline?
3896
- document.add_outline_level self, doc.sections, num_levels, expand_levels
3897
- end if doc.attr? 'outline'
3898
-
3899
- toc_section.parent.blocks.delete toc_section if toc_section
3900
-
3901
- catalog.data[:PageLabels] = state.store.ref Nums: pagenum_labels.flatten
3902
- primary_page_mode, secondary_page_mode = PageModes[(doc.attr 'pdf-page-mode') || @theme.page_mode]
3903
- catalog.data[:PageMode] = primary_page_mode
3904
- catalog.data[:NonFullScreenPageMode] = secondary_page_mode if secondary_page_mode
3905
- nil
3906
- end
3907
-
3908
- def add_outline_level outline, sections, num_levels, expand_levels
3909
- sections.each do |sect|
3910
- next if (num_levels_for_sect = (sect.attr 'outlinelevels', num_levels).to_i) < (level = sect.level)
3911
- sect_title = sanitize sect.numbered_title formal: true
3912
- sect_destination = sect.attr 'pdf-destination'
3913
- if level < num_levels_for_sect && sect.sections?
3914
- outline.section sect_title, destination: sect_destination, closed: expand_levels < 1 do
3915
- add_outline_level outline, sect.sections, num_levels_for_sect, (expand_levels - 1)
3916
- end
3917
- else
3918
- outline.page title: sect_title, destination: sect_destination
4109
+ # NOTE: base64 logic currently used for inline images
4110
+ if ::Base64 === image_path
4111
+ return @tmp_files[image_path] if @tmp_files.key? image_path
4112
+ tmp_image = ::Tempfile.create %W(image- .#{image_format})
4113
+ tmp_image.binmode unless image_format == 'svg'
4114
+ tmp_image.write ::Base64.decode64 image_path
4115
+ tmp_image.close
4116
+ @tmp_files[image_path] = tmp_image.path
4117
+ # NOTE: this will catch a classloader resource path on JRuby (e.g., uri:classloader:/path/to/image)
4118
+ elsif ::File.absolute_path? image_path
4119
+ ::File.absolute_path image_path
4120
+ elsif !(is_uri = node.is_uri? image_path) && imagesdir && (::File.absolute_path? imagesdir)
4121
+ ::File.absolute_path image_path, imagesdir
4122
+ # handle case when image is a URI
4123
+ elsif is_uri || (imagesdir && (node.is_uri? imagesdir) && (image_path = node.normalize_web_path image_path, imagesdir, false))
4124
+ if !allow_uri_read
4125
+ log :warn, %(cannot embed remote image: #{image_path} (allow-uri-read attribute not enabled))
4126
+ return
4127
+ elsif @tmp_files.key? image_path
4128
+ return @tmp_files[image_path]
3919
4129
  end
3920
- end
3921
- end
3922
-
3923
- def insert_toc_section doc, toc_title, toc_page_nums
3924
- if (doc.attr? 'toc-placement', 'macro') && (toc_node = (doc.find_by context: :toc)[0])
3925
- if (parent_section = toc_node.parent).context == :section
3926
- grandparent_section = parent_section.parent
3927
- toc_level = parent_section.level
3928
- insert_idx = (grandparent_section.blocks.index parent_section) + 1
3929
- else
3930
- grandparent_section = doc
3931
- toc_level = doc.sections[0].level
3932
- insert_idx = 0
4130
+ tmp_image = ::Tempfile.create ['image-', image_format && %(.#{image_format})]
4131
+ tmp_image.binmode if (binary = image_format != 'svg')
4132
+ begin
4133
+ load_open_uri.open_uri(image_path, (binary ? 'rb' : 'r')) {|fd| tmp_image.write fd.read }
4134
+ tmp_image.close
4135
+ @tmp_files[image_path] = tmp_image.path
4136
+ rescue
4137
+ @tmp_files[image_path] = nil
4138
+ log :warn, %(could not retrieve remote image: #{image_path}; #{$!.message})
4139
+ tmp_image.close
4140
+ unlink_tmp_file tmp_image.path
4141
+ nil
3933
4142
  end
3934
- toc_dest = toc_node.attr 'pdf-destination'
4143
+ # handle case when image is a local file
3935
4144
  else
3936
- grandparent_section = doc
3937
- toc_level = doc.sections[0].level
3938
- insert_idx = 0
3939
- toc_dest = dest_top toc_page_nums.first
4145
+ node.normalize_system_path image_path, imagesdir, nil, target_name: 'image'
3940
4146
  end
3941
- toc_section = Section.new grandparent_section, toc_level, false, attributes: { 'pdf-destination' => toc_dest }
3942
- toc_section.title = toc_title
3943
- grandparent_section.blocks.insert insert_idx, toc_section
3944
- toc_section
3945
4147
  end
3946
4148
 
3947
- def write pdf_doc, target
3948
- if target.respond_to? :write
3949
- target = ::QuantifiableStdout.new $stdout if target == $stdout
3950
- pdf_doc.render target
3951
- else
3952
- pdf_doc.render_file target
3953
- # QUESTION: restore attributes first?
3954
- @pdfmark&.generate_file target
3955
- if (quality = @optimize)
3956
- if quality.include? ','
3957
- quality, compliance = quality.split ',', 2
3958
- elsif quality.include? '/'
3959
- quality, compliance = nil, quality
4149
+ def resolve_text_align_from_role roles, query_theme: false, remove_predefined: false
4150
+ if (align_role = roles.reverse.find {|r| TextAlignmentRoles.include? r })
4151
+ roles.replace roles - TextAlignmentRoles if remove_predefined
4152
+ (align_role.slice 5, align_role.length).to_sym
4153
+ elsif query_theme
4154
+ roles.reverse.each do |role|
4155
+ if (text_align = @theme[%(role_#{role}_text_align)])
4156
+ return text_align.to_sym
3960
4157
  end
3961
- (Optimizer.new quality, pdf_doc.min_version, compliance).optimize_file target
3962
4158
  end
3963
- to_file = true
3964
- end
3965
- if !ENV['KEEP_ARTIFACTS']
3966
- remove_tmp_files
3967
- elsif to_file
3968
- scratch_target = (target.slice 0, target.length - (target_ext = ::File.extname target).length) + '-scratch' + target_ext
3969
- scratch.render_file scratch_target
4159
+ nil
3970
4160
  end
3971
- clear_scratch
3972
- nil
3973
4161
  end
3974
4162
 
3975
- def register_fonts font_catalog, fonts_dir
3976
- return unless font_catalog
3977
- dirs = (fonts_dir.split ValueSeparatorRx, -1).map do |dir|
3978
- dir == 'GEM_FONTS_DIR' || dir.empty? ? ThemeLoader::FontsDir : dir
3979
- end
3980
- font_catalog.each do |key, styles|
3981
- styles = styles.each_with_object({}) do |(style, path), accum|
3982
- found = dirs.any? do |dir|
3983
- resolved_font_path = font_path path, dir
3984
- accum[style.to_sym] = resolved_font_path if ::File.readable? resolved_font_path
3985
- end
3986
- raise ::Errno::ENOENT, ((File.absolute_path? path) ? %(#{path} not found) : %(#{path} not found in #{fonts_dir.gsub ValueSeparatorRx, ' or '})) unless found
4163
+ # Deprecated
4164
+ alias resolve_alignment_from_role resolve_text_align_from_role
4165
+
4166
+ def stamp_foreground_image doc, has_front_cover
4167
+ pages = state.pages
4168
+ if (first_page = (has_front_cover ? (pages.slice 1, pages.size) : pages).find {|it| !it.imported_page? }) &&
4169
+ (first_page_num = (pages.index first_page) + 1) &&
4170
+ (fg_image = resolve_background_image doc, @theme, 'page-foreground-image') && fg_image[0]
4171
+ go_to_page first_page_num
4172
+ create_stamp 'foreground-image' do
4173
+ canvas { image fg_image[0], ({ position: :center, vposition: :center }.merge fg_image[1]) }
4174
+ end
4175
+ stamp 'foreground-image'
4176
+ (first_page_num.next..page_count).each do |num|
4177
+ go_to_page num
4178
+ stamp 'foreground-image' unless page.imported_page?
3987
4179
  end
3988
- register_font key => styles
3989
4180
  end
3990
4181
  end
3991
4182
 
3992
- def font_path font_file, fonts_dir
3993
- # resolve relative to built-in font dir unless path is absolute
3994
- ::File.absolute_path font_file, fonts_dir
4183
+ def start_new_chapter chapter
4184
+ start_new_page unless at_page_top?
4185
+ # TODO: must call update_colors before advancing to next page if start_new_page is called in ink_chapter_title
4186
+ start_new_page if @ppbook && verso_page? && !(chapter.option? 'nonfacing')
3995
4187
  end
3996
4188
 
3997
- def fallback_svg_font_name
3998
- @theme.svg_fallback_font_family || @theme.svg_font_family || @theme.base_font_family
3999
- end
4189
+ alias start_new_part start_new_chapter
4000
4190
 
4001
- def apply_text_decoration styles, category, level = nil
4002
- if (text_decoration_style = TextDecorationStyleTable[level && @theme[%(#{category}_h#{level}_text_decoration)] || @theme[%(#{category}_text_decoration)]])
4003
- {
4004
- styles: (styles << text_decoration_style),
4005
- text_decoration_color: level && @theme[%(#{category}_h#{level}_text_decoration_color)] || @theme[%(#{category}_text_decoration_color)],
4006
- text_decoration_width: level && @theme[%(#{category}_h#{level}_text_decoration_width)] || @theme[%(#{category}_text_decoration_width)],
4007
- }.compact
4008
- else
4009
- styles.empty? ? {} : { styles: styles }
4010
- end
4011
- end
4191
+ # Returns a Boolean indicating whether the title page was created
4192
+ def start_title_page doc
4193
+ return unless doc.header? && !doc.notitle && @theme.title_page != false
4012
4194
 
4013
- def resolve_text_transform key, use_fallback = true
4014
- if (transform = ::Hash === key ? (key.delete :text_transform) : @theme[key])
4015
- transform == 'none' ? nil : transform
4016
- elsif use_fallback
4017
- @text_transform
4195
+ # NOTE: a new page may have already been started at this point, so decide what to do with it
4196
+ if page.empty?
4197
+ page.reset_content if (recycle = @ppbook ? recto_page? : true)
4198
+ elsif @ppbook && page_number > 0 && recto_page?
4199
+ start_new_page
4200
+ end
4201
+ side = page_side (recycle ? nil : page_number + 1), @folio_placement[:inverted]
4202
+ prev_bg_image = @page_bg_image[side]
4203
+ prev_bg_color = @page_bg_color
4204
+ if (bg_image = resolve_background_image doc, @theme, 'title-page-background-image')
4205
+ @page_bg_image[side] = bg_image[0] && bg_image
4018
4206
  end
4019
- end
4020
-
4021
- # QUESTION: should we pass a category as an argument?
4022
- # QUESTION: should we make this a method on the theme ostruct? (e.g., @theme.resolve_color key, fallback)
4023
- def resolve_theme_color key, fallback_color = nil, transparent_color = fallback_color
4024
- if (color = @theme[key])
4025
- color == 'transparent' ? transparent_color : color
4026
- else
4027
- fallback_color
4207
+ if (bg_color = resolve_theme_color :title_page_background_color)
4208
+ @page_bg_color = bg_color
4028
4209
  end
4210
+ recycle ? float { init_page self } : start_new_page
4211
+ @page_bg_image[side] = prev_bg_image if bg_image
4212
+ @page_bg_color = prev_bg_color if bg_color
4213
+ true
4029
4214
  end
4030
4215
 
4031
- def resolve_font_kerning keyword
4032
- FontKerningTable[keyword]
4216
+ def start_toc_page node, placement
4217
+ start_new_page unless at_page_top?
4218
+ start_new_page if @ppbook && verso_page? && !(placement == 'macro' && (node.option? 'nonfacing'))
4033
4219
  end
4034
4220
 
4035
- def theme_fill_and_stroke_bounds category, opts = {}
4036
- fill_and_stroke_bounds opts[:background_color], @theme[%(#{category}_border_color)] || @theme.base_border_color,
4037
- line_width: @theme[%(#{category}_border_width)],
4038
- line_style: (@theme[%(#{category}_border_style)]&.to_sym || :solid),
4039
- radius: @theme[%(#{category}_border_radius)]
4221
+ def supports_float_wrapping? node
4222
+ node.context == :paragraph
4040
4223
  end
4041
4224
 
4042
4225
  def theme_fill_and_stroke_block category, extent, opts = {}
@@ -4079,7 +4262,7 @@ module Asciidoctor
4079
4262
  advance_page unless first_page
4080
4263
  chunk_height = start_cursor = cursor
4081
4264
  chunk_height -= last_page.cursor if last_page
4082
- bounding_box [0, start_cursor], width: bounds.width, height: chunk_height do
4265
+ bounding_box [bounds.left, start_cursor], width: bounds.width, height: chunk_height do
4083
4266
  theme_fill_and_stroke_bounds category, background_color: bg_color
4084
4267
  unless first_page
4085
4268
  indent b_radius, b_radius do
@@ -4100,46 +4283,11 @@ module Asciidoctor
4100
4283
  nil
4101
4284
  end
4102
4285
 
4103
- # Insert a top margin equal to amount if cursor is not at the top of the
4104
- # page. Start a new page instead if amount is greater than the remaining
4105
- # space on the page.
4106
- def margin_top amount
4107
- margin amount, :top
4108
- end
4109
-
4110
- # Insert a bottom margin equal to amount unless cursor is at the top of the
4111
- # page (not likely). Start a new page instead if amount is greater than the
4112
- # remaining space on the page.
4113
- def margin_bottom amount
4114
- margin amount, :bottom
4115
- end
4116
-
4117
- # Insert a margin at the specified side if the cursor is not at the top of
4118
- # the page. Start a new page if amount is greater than the remaining space on
4119
- # the page.
4120
- def margin amount, _side
4121
- if (amount || 0) == 0 || at_page_top?
4122
- 0
4123
- # NOTE: use low-level cursor calculation to workaround cursor bug in column_box context
4124
- elsif y - reference_bounds.absolute_bottom > amount
4125
- move_down amount
4126
- amount
4127
- else
4128
- # set cursor at top of next page
4129
- reference_bounds.move_past_bottom
4130
- 0
4131
- end
4132
- end
4133
-
4134
- # Lookup margin for theme element and side, then delegate to margin method.
4135
- # If margin value is not found, assume 0.
4136
- def theme_margin category, side, node = true
4137
- if node
4138
- category = :block if node != true && node.context == :section
4139
- margin (@theme[%(#{category}_margin_#{side})] || 0), side
4140
- else
4141
- 0
4142
- end
4286
+ def theme_fill_and_stroke_bounds category, opts = {}
4287
+ fill_and_stroke_bounds opts[:background_color], @theme[%(#{category}_border_color)] || @theme.base_border_color,
4288
+ line_width: @theme[%(#{category}_border_width)],
4289
+ line_style: (@theme[%(#{category}_border_style)]&.to_sym || :solid),
4290
+ radius: @theme[%(#{category}_border_radius)]
4143
4291
  end
4144
4292
 
4145
4293
  def theme_font category, opts = {}
@@ -4198,87 +4346,15 @@ module Asciidoctor
4198
4346
  end
4199
4347
  end
4200
4348
 
4201
- # Calculate the font size (down to the minimum font size) that would allow
4202
- # all the specified fragments to fit in the available width without wrapping lines.
4203
- #
4204
- # Return the calculated font size if an adjustment is necessary or nil if no
4205
- # font size adjustment is necessary.
4206
- def compute_autofit_font_size fragments, category
4207
- arranger = arrange_fragments_by_line fragments
4208
- # NOTE: finalizing the line here generates fragments & calculates their widths using the current font settings
4209
- # NOTE: it also removes zero-width spaces
4210
- arranger.finalize_line
4211
- actual_width = width_of_fragments arranger.fragments
4212
- padding = expand_padding_value @theme[%(#{category}_padding)]
4213
- if actual_width > (available_width = bounds.width - padding[3].to_f - padding[1].to_f)
4214
- adjusted_font_size = ((available_width * font_size).to_f / actual_width).truncate 4
4215
- if (min = @theme[%(#{category}_font_size_min)] || @theme.base_font_size_min) && adjusted_font_size < min
4216
- min
4217
- else
4218
- adjusted_font_size
4219
- end
4220
- end
4221
- end
4222
-
4223
- # Arrange fragments by line in an arranger and return an unfinalized arranger.
4224
- #
4225
- # Finalizing the arranger is deferred since it must be done in the context of
4226
- # the global font settings you want applied to each fragment.
4227
- def arrange_fragments_by_line fragments, _opts = {}
4228
- arranger = ::Prawn::Text::Formatted::Arranger.new self
4229
- by_line = arranger.consumed = []
4230
- fragments.each do |fragment|
4231
- if (text = fragment[:text]) == LF || !(text.include? LF)
4232
- by_line << fragment
4233
- else
4234
- text.scan LineScanRx do |line|
4235
- by_line << (line == LF ? { text: LF } : (fragment.merge text: line))
4236
- end
4237
- end
4238
- end
4239
- arranger
4240
- end
4241
-
4242
- # Calculate the width that is needed to print all the
4243
- # fragments without wrapping any lines.
4244
- #
4245
- # This method assumes endlines are represented as discrete entries in the
4246
- # fragments array.
4247
- def width_of_fragments fragments
4248
- line_widths = [0]
4249
- fragments.each do |fragment|
4250
- if fragment.text == LF
4251
- line_widths << 0
4252
- else
4253
- line_widths[-1] += fragment.width
4254
- end
4255
- end
4256
- line_widths.max
4257
- end
4258
-
4259
- # Compute the rendered width of a string, taking fallback fonts into account
4260
- def rendered_width_of_string str, opts = {}
4261
- opts = opts.merge kerning: default_kerning?
4262
- if str.length == 1
4263
- rendered_width_of_char str, opts
4264
- elsif (chars = str.each_char).all? {|char| font.glyph_present? char }
4265
- width_of_string str, opts
4349
+ # Lookup margin for theme element and side, then delegate to margin method.
4350
+ # If margin value is not found, assume 0.
4351
+ def theme_margin category, side, node = true
4352
+ if node
4353
+ category = :block if node != true && node.context == :section
4354
+ margin (@theme[%(#{category}_margin_#{side})] || 0), side
4266
4355
  else
4267
- char_widths = chars.map {|char| rendered_width_of_char char, opts }
4268
- char_widths.sum + (char_widths.length * character_spacing)
4269
- end
4270
- end
4271
-
4272
- # Compute the rendered width of a char, taking fallback fonts into account
4273
- def rendered_width_of_char char, opts = {}
4274
- unless @fallback_fonts.empty? || (font.glyph_present? char)
4275
- @fallback_fonts.each do |fallback_font|
4276
- font fallback_font do
4277
- return width_of_string char, opts if font.glyph_present? char
4278
- end
4279
- end
4356
+ 0
4280
4357
  end
4281
- width_of_string char, opts
4282
4358
  end
4283
4359
 
4284
4360
  # TODO: document me, esp the first line formatting functionality
@@ -4286,7 +4362,7 @@ module Asciidoctor
4286
4362
  opts = { leading: line_metrics.leading, final_gap: line_metrics.final_gap }.merge opts
4287
4363
  string = string.gsub CjkLineBreakRx, ZeroWidthSpace if @cjk_line_breaks
4288
4364
  return text_box string, opts if opts[:height]
4289
- move_down line_metrics.padding_top
4365
+ opts[:initial_gap] = line_metrics.padding_top
4290
4366
  if (hanging_indent = (opts.delete :hanging_indent) || 0) > 0
4291
4367
  indent hanging_indent do
4292
4368
  text string, (opts.merge indent_paragraphs: -hanging_indent)
@@ -4302,8 +4378,7 @@ module Asciidoctor
4302
4378
 
4303
4379
  # QUESTION: combine with typeset_text?
4304
4380
  def typeset_formatted_text fragments, line_metrics, opts = {}
4305
- move_down line_metrics.padding_top
4306
- opts = { leading: line_metrics.leading, final_gap: line_metrics.final_gap }.merge opts
4381
+ opts = { leading: line_metrics.leading, initial_gap: line_metrics.padding_top, final_gap: line_metrics.final_gap }.merge opts
4307
4382
  if (hanging_indent = (opts.delete :hanging_indent) || 0) > 0
4308
4383
  indent hanging_indent do
4309
4384
  formatted_text fragments, (opts.merge indent_paragraphs: -hanging_indent)
@@ -4314,94 +4389,67 @@ module Asciidoctor
4314
4389
  move_down line_metrics.padding_bottom
4315
4390
  end
4316
4391
 
4317
- def height_of_typeset_text string, opts = {}
4318
- line_metrics = (calc_line_metrics opts[:line_height] || @base_line_height)
4319
- (height_of string, leading: line_metrics.leading, final_gap: line_metrics.final_gap) + line_metrics.padding_top + (opts[:single_line] ? 0 : line_metrics.padding_bottom)
4320
- end
4321
-
4322
- # NOTE: only used when tabsize attribute is not specified
4323
- # tabs must always be replaced with spaces in order for the indentation guards to work
4324
- def expand_tabs string
4325
- if string.nil_or_empty?
4326
- ''
4327
- elsif string.include? TAB
4328
- full_tab_space = ' ' * (tab_size = 4)
4329
- (string.split LF, -1).map do |line|
4330
- if line.empty? || !(tab_idx = line.index TAB)
4331
- line
4332
- else
4333
- if tab_idx == 0
4334
- leading_tabs = 0
4335
- line.each_byte do |b|
4336
- break unless b == 9
4337
- leading_tabs += 1
4338
- end
4339
- line = %(#{full_tab_space * leading_tabs}#{rest = line.slice leading_tabs, line.length})
4340
- next line unless rest.include? TAB
4341
- end
4342
- # keeps track of how many spaces were added to adjust offset in match data
4343
- spaces_added = 0
4344
- idx = 0
4345
- result = ''
4346
- line.each_char do |c|
4347
- if c == TAB
4348
- # calculate how many spaces this tab represents, then replace tab with spaces
4349
- if (offset = idx + spaces_added) % tab_size == 0
4350
- spaces_added += (tab_size - 1)
4351
- result += full_tab_space
4352
- else
4353
- unless (spaces = tab_size - offset % tab_size) == 1
4354
- spaces_added += (spaces - 1)
4355
- end
4356
- result += (' ' * spaces)
4357
- end
4358
- else
4359
- result += c
4360
- end
4361
- idx += 1
4362
- end
4363
- result
4364
- end
4365
- end.join LF
4392
+ def write pdf_doc, target
4393
+ if target.respond_to? :write
4394
+ target = ::QuantifiableStdout.new $stdout if target == $stdout
4395
+ pdf_doc.render target
4366
4396
  else
4367
- string
4397
+ pdf_doc.render_file target
4398
+ # QUESTION: restore attributes first?
4399
+ @pdfmark&.generate_file target
4400
+ if (quality = @optimize)
4401
+ if quality.include? ','
4402
+ quality, compliance = quality.split ',', 2
4403
+ elsif quality.include? '/'
4404
+ quality, compliance = nil, quality
4405
+ end
4406
+ (Optimizer.new quality, pdf_doc.min_version, compliance).optimize_file target
4407
+ end
4408
+ to_file = true
4409
+ end
4410
+ if !ENV['KEEP_ARTIFACTS']
4411
+ remove_tmp_files
4412
+ elsif to_file
4413
+ scratch_target = (target.slice 0, target.length - (target_ext = ::File.extname target).length) + '-scratch' + target_ext
4414
+ scratch.render_file scratch_target
4368
4415
  end
4416
+ clear_scratch
4417
+ nil
4369
4418
  end
4370
4419
 
4371
- # Add an indentation guard at the start of indented lines.
4372
- # Expand tabs to spaces if tabs are present
4373
- def guard_indentation string
4374
- unless (string = expand_tabs string).empty?
4375
- string[0] = GuardedIndent if string.start_with? ' '
4376
- string.gsub! InnerIndent, GuardedInnerIndent if string.include? InnerIndent
4420
+ # Deprecated method names
4421
+ alias layout_footnotes ink_footnotes
4422
+ alias layout_title_page ink_title_page
4423
+ alias layout_cover_page ink_cover_page
4424
+ alias layout_chapter_title ink_chapter_title
4425
+ alias layout_part_title ink_part_title
4426
+ alias layout_general_heading ink_general_heading
4427
+ alias layout_heading ink_heading
4428
+ alias layout_prose ink_prose
4429
+ alias layout_caption ink_caption
4430
+ alias layout_table_caption ink_table_caption
4431
+ alias layout_toc ink_toc
4432
+ alias layout_toc_level ink_toc_level
4433
+ alias layout_running_content ink_running_content
4434
+
4435
+ # intercepts "class CustomPDFConverter < (Asciidoctor::Converter.for 'pdf')"
4436
+ def self.method_added method_sym
4437
+ if (method_name = method_sym.to_s).start_with? 'layout_'
4438
+ alias_method %(ink_#{method_name.slice 7, method_name.length}).to_sym, method_sym
4439
+ elsif method_name == 'convert_listing_or_literal' || method_name == 'convert_code'
4440
+ alias_method :convert_listing, method_sym
4441
+ alias_method :convert_literal, method_sym
4377
4442
  end
4378
- string
4379
4443
  end
4380
4444
 
4381
- def guard_indentation_in_fragments fragments
4382
- start_of_line = true
4383
- fragments.each do |fragment|
4384
- next if (text = fragment[:text]).empty?
4385
- if start_of_line && (text.start_with? ' ')
4386
- fragment[:text] = GuardedIndent + (((text = text.slice 1, text.length).include? InnerIndent) ? (text.gsub InnerIndent, GuardedInnerIndent) : text)
4387
- elsif text.include? InnerIndent
4388
- fragment[:text] = text.gsub InnerIndent, GuardedInnerIndent
4389
- end
4390
- start_of_line = text.end_with? LF
4391
- end
4392
- fragments
4445
+ # intercepts "(Asciidoctor::Converter.for 'pdf').prepend CustomConverterExtensions"
4446
+ def self.prepend *mods
4447
+ super
4448
+ mods.each {|mod| (mod.instance_methods false).each {|method| method_added method } }
4449
+ self
4393
4450
  end
4394
4451
 
4395
- # Derive a PDF-safe, ASCII-only anchor name from the given value.
4396
- # Encodes value into hex if it contains characters outside the ASCII range.
4397
- # If value is nil, derive an anchor name from the default_value, if given.
4398
- def derive_anchor_from_id value, default_value = nil
4399
- if value
4400
- value.ascii_only? ? value : %(0x#{::PDF::Core.string_to_hex value})
4401
- else
4402
- %(__anchor-#{default_value})
4403
- end
4404
- end
4452
+ private
4405
4453
 
4406
4454
  def add_dest_for_top doc
4407
4455
  unless (top_page = doc.attr 'pdf-page-start') > page_count
@@ -4414,302 +4462,359 @@ module Asciidoctor
4414
4462
  nil
4415
4463
  end
4416
4464
 
4417
- # If an id is provided or the node passed as the first argument has an id,
4418
- # add a named destination to the document equivalent to the node id at the
4419
- # current y position. If the node does not have an id and an id is not
4420
- # specified, do nothing.
4421
- #
4422
- # If the node is a section, and the current y position is the top of the
4423
- # page, set the y position equal to the page height to improve the navigation
4424
- # experience. If the current x position is at or inside the left margin, set
4425
- # the x position equal to 0 (left edge of page) to improve the navigation
4426
- # experience.
4427
- def add_dest_for_block node, id: nil, y: nil
4428
- if !scratch? && (id ||= node.id)
4429
- dest_x = bounds.absolute_left.truncate 4
4430
- # QUESTION: when content is aligned to left margin, should we keep precise x value or just use 0?
4431
- dest_x = 0 if dest_x <= page_margin_left
4432
- unless (dest_y = y)
4433
- dest_y = @y
4434
- dest_y += [page_height - dest_y, -@theme.block_anchor_top.to_f].min
4465
+ def add_link_to_image uri, image_info, image_opts
4466
+ image_width = image_info[:width]
4467
+ image_height = image_info[:height]
4468
+
4469
+ case image_opts[:position]
4470
+ when :center
4471
+ image_x = bounds.left_side + (bounds.width - image_width) * 0.5
4472
+ when :right
4473
+ image_x = bounds.right_side - image_width
4474
+ else # :left, nil
4475
+ image_x = bounds.left_side
4476
+ end
4477
+
4478
+ case image_opts[:vposition]
4479
+ when :top
4480
+ image_y = bounds.absolute_top
4481
+ when :center
4482
+ image_y = bounds.absolute_top - (bounds.height - image_height) * 0.5
4483
+ when :bottom
4484
+ image_y = bounds.absolute_bottom + image_height
4485
+ else
4486
+ image_y = y - image_opts[:vposition]
4487
+ end unless (image_y = image_opts[:y])
4488
+
4489
+ link_annotation [image_x, (image_y - image_height), (image_x + image_width), image_y], Border: [0, 0, 0], A: { Type: :Action, S: :URI, URI: uri.as_pdf }
4490
+ end
4491
+
4492
+ def admonition_icon_data key
4493
+ if (icon_data = @theme[%(admonition_icon_#{key})])
4494
+ icon_data = (AdmonitionIcons[key] || {}).merge icon_data
4495
+ if (icon_name = icon_data[:name])
4496
+ unless icon_name.start_with?(*IconSetPrefixes)
4497
+ log(:info) { %(#{key} admonition in theme uses icon from deprecated fa icon set; use fas, far, or fab instead) }
4498
+ icon_data[:name] = %(fa-#{icon_name}) unless icon_name.start_with? 'fa-'
4499
+ end
4500
+ else
4501
+ icon_data[:name] = AdmonitionIcons[:note][:name]
4435
4502
  end
4436
- # TODO: find a way to store only the ref of the destination; look it up when we need it
4437
- node.set_attr 'pdf-destination', (node_dest = (dest_xyz dest_x, dest_y))
4438
- add_dest id, node_dest
4503
+ else
4504
+ (icon_data = AdmonitionIcons[key] || {})[:name] ||= AdmonitionIcons[:note][:name]
4439
4505
  end
4440
- nil
4506
+ icon_data
4441
4507
  end
4442
4508
 
4443
- def resolve_doctitle doc, partition = nil
4444
- if doc.header?
4445
- doc.doctitle partition: partition
4446
- elsif partition
4447
- ::Asciidoctor::Document::Title.new (doc.attr 'untitled-label'), separator: (doc.attr 'title-separator')
4509
+ def allocate_space_for_list_item line_metrics
4510
+ advance_page if !at_page_top? && cursor < line_metrics.height + line_metrics.leading + line_metrics.padding_top
4511
+ end
4512
+
4513
+ def apply_text_decoration styles, category, level = nil
4514
+ if (text_decoration_style = TextDecorationStyleTable[level && @theme[%(#{category}_h#{level}_text_decoration)] || @theme[%(#{category}_text_decoration)]])
4515
+ {
4516
+ styles: (styles << text_decoration_style),
4517
+ text_decoration_color: level && @theme[%(#{category}_h#{level}_text_decoration_color)] || @theme[%(#{category}_text_decoration_color)],
4518
+ text_decoration_width: level && @theme[%(#{category}_h#{level}_text_decoration_width)] || @theme[%(#{category}_text_decoration_width)],
4519
+ }.compact
4448
4520
  else
4449
- doc.attr 'untitled-label'
4521
+ styles.empty? ? {} : { styles: styles }
4450
4522
  end
4451
4523
  end
4452
4524
 
4453
- def resolve_text_align_from_role roles, query_theme: false, remove_predefined: false
4454
- if (align_role = roles.reverse.find {|r| TextAlignmentRoles.include? r })
4455
- roles.replace roles - TextAlignmentRoles if remove_predefined
4456
- (align_role.slice 5, align_role.length).to_sym
4457
- elsif query_theme
4458
- roles.reverse.each do |role|
4459
- if (align = @theme[%(role_#{role}_text_align)])
4460
- return align.to_sym
4525
+ # Arrange fragments by line in an arranger and return an unfinalized arranger.
4526
+ #
4527
+ # Finalizing the arranger is deferred since it must be done in the context of
4528
+ # the global font settings you want applied to each fragment.
4529
+ def arrange_fragments_by_line fragments, _opts = {}
4530
+ arranger = ::Prawn::Text::Formatted::Arranger.new self
4531
+ by_line = arranger.consumed = []
4532
+ fragments.each do |fragment|
4533
+ if (text = fragment[:text]) == LF || !(text.include? LF)
4534
+ by_line << fragment
4535
+ else
4536
+ text.scan LineScanRx do |line|
4537
+ by_line << (line == LF ? { text: LF } : (fragment.merge text: line))
4461
4538
  end
4462
4539
  end
4463
- nil
4464
4540
  end
4541
+ arranger
4465
4542
  end
4466
4543
 
4467
- # Deprecated
4468
- alias resolve_alignment_from_role resolve_text_align_from_role
4544
+ # NOTE: assume URL is escaped (i.e., contains character references such as &amp;)
4545
+ def breakable_uri uri
4546
+ scheme, address = uri.split UriSchemeBoundaryRx, 2
4547
+ address, scheme = scheme, address unless address
4548
+ unless address.nil_or_empty?
4549
+ address = address.gsub UriBreakCharsRx, UriBreakCharRepl
4550
+ # NOTE: require at least two characters after a break
4551
+ address.slice!(-2) if address[-2] == ZeroWidthSpace
4552
+ end
4553
+ %(#{scheme}#{address})
4554
+ end
4469
4555
 
4470
- # QUESTION: is this method still necessary?
4471
- def resolve_imagesdir doc
4472
- if (imagesdir = doc.attr 'imagesdir').nil_or_empty? || (imagesdir = imagesdir.chomp '/') == '.'
4473
- nil
4556
+ # Calculate the font size (down to the minimum font size) that would allow
4557
+ # all the specified fragments to fit in the available width without wrapping lines.
4558
+ #
4559
+ # Return the calculated font size if an adjustment is necessary or nil if no
4560
+ # font size adjustment is necessary.
4561
+ def compute_autofit_font_size fragments, category
4562
+ arranger = arrange_fragments_by_line fragments
4563
+ # NOTE: finalizing the line here generates fragments & calculates their widths using the current font settings
4564
+ # NOTE: it also removes zero-width spaces
4565
+ arranger.finalize_line
4566
+ actual_width = width_of_fragments arranger.fragments
4567
+ padding = expand_padding_value @theme[%(#{category}_padding)]
4568
+ if actual_width > (available_width = bounds.width - padding[3].to_f - padding[1].to_f)
4569
+ adjusted_font_size = ((available_width * font_size).to_f / actual_width).truncate 4
4570
+ if (min = @theme[%(#{category}_font_size_min)] || @theme.base_font_size_min) && adjusted_font_size < min
4571
+ min
4572
+ else
4573
+ adjusted_font_size
4574
+ end
4575
+ end
4576
+ end
4577
+
4578
+ def consolidate_ranges nums
4579
+ if nums.size > 1
4580
+ prev = nil
4581
+ nums.each_with_object [] do |num, accum|
4582
+ if prev && (prev.to_i + 1) == num.to_i
4583
+ accum[-1][1] = num
4584
+ else
4585
+ accum << [num]
4586
+ end
4587
+ prev = num
4588
+ end.map {|range| range.join '-' }
4474
4589
  else
4475
- imagesdir
4590
+ nums
4476
4591
  end
4477
4592
  end
4478
4593
 
4479
- # Resolve the system path of the specified image path.
4480
- #
4481
- # Resolve and normalize the absolute system path of the specified image,
4482
- # taking into account the imagesdir attribute. If an image path is not
4483
- # specified, the path is read from the target attribute of the specified
4484
- # document node.
4485
- #
4486
- # If the target is a URI and the allow-uri-read attribute is set on the
4487
- # document, read the file contents to a temporary file and return the path to
4488
- # the temporary file. If the target is a URI and the allow-uri-read attribute
4489
- # is not set, or the URI cannot be read, this method returns a nil value.
4490
- #
4491
- # When a temporary file is used, the file is stored in @tmp_files to be cleaned up after conversion.
4492
- def resolve_image_path node, image_path, image_format, relative_to = true
4493
- doc = node.document
4494
- imagesdir = relative_to == true ? (resolve_imagesdir doc) : relative_to
4495
- # NOTE: base64 logic currently used for inline images
4496
- if ::Base64 === image_path
4497
- return @tmp_files[image_path] if @tmp_files.key? image_path
4498
- tmp_image = ::Tempfile.create %W(image- .#{image_format})
4499
- tmp_image.binmode unless image_format == 'svg'
4500
- tmp_image.write ::Base64.decode64 image_path
4501
- tmp_image.close
4502
- @tmp_files[image_path] = tmp_image.path
4503
- # NOTE: this will catch a classloader resource path on JRuby (e.g., uri:classloader:/path/to/image)
4504
- elsif ::File.absolute_path? image_path
4505
- ::File.absolute_path image_path
4506
- elsif !(is_uri = node.is_uri? image_path) && imagesdir && (::File.absolute_path? imagesdir)
4507
- ::File.absolute_path image_path, imagesdir
4508
- # handle case when image is a URI
4509
- elsif is_uri || (imagesdir && (node.is_uri? imagesdir) && (image_path = node.normalize_web_path image_path, imagesdir, false))
4510
- if !allow_uri_read
4511
- log :warn, %(cannot embed remote image: #{image_path} (allow-uri-read attribute not enabled))
4512
- return
4513
- elsif @tmp_files.key? image_path
4514
- return @tmp_files[image_path]
4594
+ def conum_glyph number
4595
+ @conum_glyphs[number - 1]
4596
+ end
4597
+
4598
+ # Derive a PDF-safe, ASCII-only anchor name from the given value.
4599
+ # Encodes value into hex if it contains characters outside the ASCII range.
4600
+ # If value is nil, derive an anchor name from the default_value, if given.
4601
+ def derive_anchor_from_id value, default_value = nil
4602
+ if value
4603
+ value.ascii_only? ? value : %(0x#{::PDF::Core.string_to_hex value})
4604
+ else
4605
+ %(__anchor-#{default_value})
4606
+ end
4607
+ end
4608
+
4609
+ def draw_image_border top, w, h, alignment
4610
+ if (Array @theme.image_border_width).any? {|it| it&.> 0 } && (@theme.image_border_color || @theme.base_border_color)
4611
+ if (@theme.image_border_fit || 'content') == 'auto'
4612
+ bb_width = bounds.width
4613
+ elsif alignment == :center
4614
+ bb_x = (bounds.width - w) * 0.5
4615
+ elsif alignment == :right
4616
+ bb_x = bounds.width - w
4515
4617
  end
4516
- tmp_image = ::Tempfile.create ['image-', image_format && %(.#{image_format})]
4517
- tmp_image.binmode if (binary = image_format != 'svg')
4518
- begin
4519
- load_open_uri.open_uri(image_path, (binary ? 'rb' : 'r')) {|fd| tmp_image.write fd.read }
4520
- tmp_image.close
4521
- @tmp_files[image_path] = tmp_image.path
4522
- rescue
4523
- @tmp_files[image_path] = nil
4524
- log :warn, %(could not retrieve remote image: #{image_path}; #{$!.message})
4525
- tmp_image.close
4526
- unlink_tmp_file tmp_image.path
4527
- nil
4618
+ bounding_box [(bb_x || 0), top], width: (bb_width || w), height: h, position: alignment do
4619
+ theme_fill_and_stroke_bounds :image
4528
4620
  end
4529
- # handle case when image is a local file
4530
- else
4531
- node.normalize_system_path image_path, imagesdir, nil, target_name: 'image'
4621
+ true
4532
4622
  end
4533
4623
  end
4534
4624
 
4535
- def resolve_icon_image_path node, type, resolve = true
4536
- if (data_uri_enabled = (doc = node.document).attr? 'data-uri')
4537
- doc.remove_attr 'data-uri'
4625
+ # Reduce icon height to fit inside bounds.height. Icons will not render
4626
+ # properly if they are larger than the current bounds.height.
4627
+ def fit_icon_to_bounds preferred_size
4628
+ (max_height = bounds.height) < preferred_size ? max_height : preferred_size
4629
+ end
4630
+
4631
+ def font_path font_file, fonts_dir
4632
+ # resolve relative to built-in font dir unless path is absolute
4633
+ ::File.absolute_path font_file, fonts_dir
4634
+ end
4635
+
4636
+ def generate_manname_section node
4637
+ title = node.attr 'manname-title', 'Name'
4638
+ if (next_section_title = node.sections[0]&.title) && next_section_title.upcase == next_section_title
4639
+ title = title.upcase
4538
4640
  end
4641
+ sect = Section.new node, 1
4642
+ sect.sectname = 'section'
4643
+ sect.id = node.attr 'manname-id'
4644
+ sect.title = title
4645
+ sect << (Block.new sect, :paragraph, source: %(#{node.attr 'manname'} - #{node.attr 'manpurpose'}), subs: :normal)
4646
+ sect
4647
+ end
4648
+
4649
+ def get_char code
4650
+ (code.start_with? '\u') ? ([((code.slice 2, code.length).to_i 16)].pack 'U1') : code
4651
+ end
4652
+
4653
+ def get_icon_image_path node, type, resolve = true
4654
+ doc = node.document
4655
+ doc.remove_attr 'data-uri' if (data_uri_enabled = doc.attr? 'data-uri')
4539
4656
  # NOTE: icon_uri will consider icon attribute on node first, then type
4540
4657
  icon_path, icon_format = ::Asciidoctor::Image.target_and_format node.icon_uri type
4541
4658
  doc.set_attr 'data-uri', '' if data_uri_enabled
4542
4659
  resolve ? (resolve_image_path node, icon_path, icon_format, nil) : icon_path
4543
4660
  end
4544
4661
 
4545
- # Resolve the path and sizing of the background image either from a document attribute or theme key.
4546
- #
4547
- # Returns the argument list for the image method if the document attribute or theme key is found. Otherwise,
4548
- # nothing. The first argument in the argument list is the image path. If that value is nil, the background
4549
- # image is disabled. The second argument is the options hash to specify the dimensions, such as width and fit.
4550
- def resolve_background_image doc, theme, key, opts = {}
4551
- if ::String === key
4552
- theme_key = opts.delete :theme_key
4553
- image_path = (doc.attr key) || (from_theme = theme[theme_key || (key.tr '-', '_').to_sym])
4554
- else
4555
- image_path = from_theme = theme[key]
4662
+ def init_float_box _node, block_width, block_height, float_to
4663
+ gap = ::Array === (gap = @theme.image_float_gap) ? gap.dup : [gap, gap]
4664
+ float_w = block_width + (gap[0] ||= 12)
4665
+ float_h = block_height + (gap[1] ||= 6)
4666
+ box_l = bounds.left + (float_to == 'right' ? 0 : float_w)
4667
+ box_t = cursor + block_height
4668
+ box_w = bounds.width - float_w
4669
+ box_r = box_l + box_w
4670
+ box_h = [box_t, float_h].min
4671
+ box_b = box_t - box_h
4672
+ move_cursor_to box_t
4673
+ @float_box = { page: page_number, top: box_t, right: box_r, bottom: box_b, left: box_l, width: box_w, height: box_h, gap: gap }
4674
+ end
4675
+
4676
+ # NOTE: init_page is called within a float context; this will suppress prawn-svg messing with the cursor
4677
+ # NOTE: init_page is not called for imported pages, front and back cover pages, and other image pages
4678
+ def init_page *_args
4679
+ next_page_side = page_side nil, @folio_placement[:inverted]
4680
+ if @media == 'prepress' && (next_page_margin = @page_margin_by_side[page_number == 1 ? :cover : next_page_side]) != page_margin
4681
+ set_page_margin next_page_margin
4556
4682
  end
4557
- symbolic_paths = opts.delete :symbolic_paths
4558
- if image_path
4559
- if symbolic_paths&.include? image_path
4560
- return [image_path, {}]
4561
- elsif image_path == 'none'
4562
- return []
4563
- elsif (image_path.include? ':') && image_path =~ ImageAttributeValueRx
4564
- image_attrs = (AttributeList.new $2).parse %w(alt width)
4565
- if from_theme
4566
- image_path = apply_subs_discretely doc, $1, subs: [:attributes]
4567
- image_relative_to = @themesdir
4683
+ unless @page_bg_color == 'FFFFFF'
4684
+ tare = true
4685
+ fill_absolute_bounds @page_bg_color
4686
+ end
4687
+ if (bg_image_path, bg_image_opts = @page_bg_image[next_page_side])
4688
+ tare = true
4689
+ begin
4690
+ if bg_image_opts[:format] == 'pdf'
4691
+ # NOTE: pages that use PDF for the background do not support a background color or running content
4692
+ # IMPORTANT: the background PDF must have the same dimensions as the current PDF
4693
+ import_page bg_image_path, (bg_image_opts.merge replace: true, advance: false, advance_if_missing: false)
4568
4694
  else
4569
- image_path = $1
4570
- image_relative_to = true
4695
+ canvas { image bg_image_path, ({ position: :center, vposition: :center }.merge bg_image_opts) }
4571
4696
  end
4572
- elsif from_theme
4573
- image_path = apply_subs_discretely doc, image_path, subs: [:attributes]
4574
- image_relative_to = @themesdir
4697
+ rescue
4698
+ facing_page_side = (PageSides - [next_page_side])[0]
4699
+ @page_bg_image[facing_page_side] = nil if @page_bg_image[facing_page_side] == @page_bg_image[next_page_side]
4700
+ @page_bg_image[next_page_side] = nil
4701
+ log :warn, %(could not embed page background image: #{bg_image_path}; #{$!.message})
4575
4702
  end
4703
+ end
4704
+ page.tare_content_stream if tare
4705
+ end
4576
4706
 
4577
- image_path, image_format = ::Asciidoctor::Image.target_and_format image_path, image_attrs
4578
- image_path = resolve_image_path doc, image_path, image_format, image_relative_to
4579
-
4580
- return unless image_path
4581
-
4582
- unless ::File.readable? image_path
4583
- log :warn, %(#{key.to_s.tr '-_', ' '} not found or readable: #{image_path})
4584
- return
4707
+ def ink_paragraph_in_float_box node, float_box, prose_opts, role_keys, block_next, insert_margin_bottom
4708
+ @float_box = para_font_descender = para_font_size = end_cursor = nil
4709
+ if role_keys
4710
+ line_metrics = theme_font_cascade role_keys do
4711
+ para_font_descender = font.descender
4712
+ para_font_size = font_size
4713
+ calc_line_metrics @base_line_height
4585
4714
  end
4586
-
4587
- if image_format == 'pdf'
4588
- [image_path, page: [((image_attrs || {})['page']).to_i, 1].max, format: image_format]
4715
+ else
4716
+ para_font_descender = font.descender
4717
+ para_font_size = font_size
4718
+ line_metrics = calc_line_metrics @base_line_height
4719
+ end
4720
+ # allocate the space of at least one empty line below block
4721
+ line_height_length = line_metrics.height + line_metrics.leading + line_metrics.padding_top
4722
+ start_page_number = float_box[:page]
4723
+ start_cursor = cursor
4724
+ block_bottom = (float_box_bottom = float_box[:bottom]) + float_box[:gap][1]
4725
+ # use :at to incorporate padding top from line metrics since text_box method does not apply it
4726
+ # use :final_gap to incorporate padding bottom from line metrics
4727
+ # use :draw_text_callback to track end cursor (requires applying :final_gap to result manually)
4728
+ prose_opts.update \
4729
+ at: [float_box[:left], start_cursor - line_metrics.padding_top],
4730
+ width: float_box[:width],
4731
+ height: [cursor, float_box[:height] - (float_box[:top] - start_cursor) + line_height_length].min,
4732
+ final_gap: para_font_descender + line_metrics.padding_bottom,
4733
+ draw_text_callback: (proc do |text, opts|
4734
+ draw_text! text, opts
4735
+ end_cursor = opts[:at][1] # does not include :final_gap value
4736
+ end)
4737
+ overflow_text = role_keys ?
4738
+ theme_font_cascade(role_keys) { ink_prose node.content, prose_opts } :
4739
+ (ink_prose node.content, prose_opts)
4740
+ move_cursor_to end_cursor -= prose_opts[:final_gap] if end_cursor # ink_prose with :height does not move cursor
4741
+ if overflow_text.empty?
4742
+ if block_next && (supports_float_wrapping? block_next)
4743
+ insert_margin_bottom.call
4744
+ @float_box = float_box if page_number == start_page_number && cursor > start_cursor - prose_opts[:height]
4745
+ elsif end_cursor > block_bottom
4746
+ move_cursor_to block_bottom
4747
+ theme_margin :block, :bottom, block_next
4589
4748
  else
4590
- [image_path, (resolve_image_options image_path, image_format, image_attrs, (({ background: true, container_size: [page_width, page_height] }.merge opts)))]
4749
+ insert_margin_bottom.call
4750
+ end
4751
+ else
4752
+ overflow_prose_opts = { align: prose_opts[:align] || @base_text_align.to_sym }
4753
+ unless end_cursor
4754
+ overflow_prose_opts[:indent_paragraphs] = prose_opts[:indent_paragraphs]
4755
+ move_cursor_to float_box_bottom if start_cursor > float_box_bottom
4591
4756
  end
4757
+ role_keys ?
4758
+ theme_font_cascade(role_keys) { typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts } :
4759
+ (typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts)
4760
+ insert_margin_bottom.call
4592
4761
  end
4593
4762
  end
4594
4763
 
4595
- def resolve_image_options image_path, image_format, image_attrs, opts = {}
4596
- if image_format == 'svg'
4597
- image_opts = {
4598
- enable_file_requests_with_root: (::File.dirname image_path),
4599
- enable_web_requests: allow_uri_read ? (method :load_open_uri).to_proc : false,
4600
- cache_images: cache_uri,
4601
- fallback_font_name: fallback_svg_font_name,
4602
- format: 'svg',
4603
- }
4764
+ def insert_toc_section doc, toc_title, toc_page_nums
4765
+ if (doc.attr? 'toc-placement', 'macro') && (toc_node = (doc.find_by context: :toc)[0])
4766
+ if (parent_section = toc_node.parent).context == :section
4767
+ grandparent_section = parent_section.parent
4768
+ toc_level = parent_section.level
4769
+ insert_idx = (grandparent_section.blocks.index parent_section) + 1
4770
+ else
4771
+ grandparent_section = doc
4772
+ toc_level = doc.sections[0].level
4773
+ insert_idx = 0
4774
+ end
4775
+ toc_dest = toc_node.attr 'pdf-destination'
4604
4776
  else
4605
- image_opts = {}
4777
+ grandparent_section = doc
4778
+ toc_level = doc.sections[0].level
4779
+ insert_idx = 0
4780
+ toc_dest = dest_top toc_page_nums.first
4606
4781
  end
4607
- container_size = opts[:container_size]
4608
- if image_attrs
4609
- if (alt = image_attrs['alt'])
4610
- image_opts[:alt] = %([#{alt}])
4611
- end
4612
- if (background = opts[:background]) && (image_pos = image_attrs['position']) && (image_pos = resolve_background_position image_pos, nil)
4613
- image_opts.update image_pos
4614
- end
4615
- if (image_fit = image_attrs['fit'] || (background ? 'contain' : nil))
4616
- image_fit = 'contain' if image_format == 'svg' && image_fit == 'fill'
4617
- container_width, container_height = container_size
4618
- case image_fit
4619
- when 'none'
4620
- if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4621
- image_opts[:width] = image_width
4622
- end
4623
- when 'scale-down'
4624
- # NOTE: if width and height aren't set in SVG, real width and height are computed after stretching viewbox to fit page
4625
- if (image_width = resolve_explicit_width image_attrs, bounds_width: container_width)
4626
- if image_width > container_width
4627
- image_opts[:fit] = container_size
4628
- else
4629
- image_size = intrinsic_image_dimensions image_path, image_format
4630
- if image_width * (image_size[:height].to_f / image_size[:width]) > container_height
4631
- image_opts[:fit] = container_size
4632
- else
4633
- image_opts[:width] = image_width
4634
- end
4635
- end
4636
- else
4637
- image_size = intrinsic_image_dimensions image_path, image_format
4638
- image_opts[:fit] = container_size if image_size[:width] > container_width || image_size[:height] > container_height
4639
- end
4640
- when 'cover'
4641
- # QUESTION: should we take explicit width into account?
4642
- image_size = intrinsic_image_dimensions image_path, image_format
4643
- if container_width * (image_size[:height].to_f / image_size[:width]) < container_height
4644
- image_opts[:height] = container_height
4645
- else
4646
- image_opts[:width] = container_width
4647
- end
4648
- when 'fill'
4649
- image_opts[:width] = container_width
4650
- image_opts[:height] = container_height
4651
- else # 'contain'
4652
- image_opts[:fit] = container_size
4653
- end
4654
- elsif (image_width = resolve_explicit_width image_attrs, bounds_width: container_size[0])
4655
- image_opts[:width] = image_width
4656
- else # default to fit=contain if sizing is not specified
4657
- image_opts[:fit] = container_size
4658
- end
4782
+ toc_section = Section.new grandparent_section, toc_level, false, attributes: { 'pdf-destination' => toc_dest }
4783
+ toc_section.title = toc_title
4784
+ grandparent_section.blocks.insert insert_idx, toc_section
4785
+ toc_section
4786
+ end
4787
+
4788
+ def load_open_uri
4789
+ if @cache_uri && !(defined? ::OpenURI::Cache) && (Helpers.require_library 'open-uri/cached', 'open-uri-cached', :warn).nil?
4790
+ # disable URI caching if library fails to load
4791
+ @cache_uri = false
4792
+ end
4793
+ ::OpenURI
4794
+ end
4795
+
4796
+ def on_image_error _reason, node, target, opts
4797
+ log :warn, opts[:message] if opts.key? :message
4798
+ alt_text_vars = { alt: (node.attr 'alt'), target: target }
4799
+ alt_text_template = @theme.image_alt_content || '%{link}[%{alt}]%{/link} | <em>%{target}</em>' # rubocop:disable Style/FormatStringToken
4800
+ return if alt_text_template.empty?
4801
+ if (link = node.attr 'link')
4802
+ alt_text_vars[:link] = %(<a href="#{link}">)
4803
+ alt_text_vars[:'/link'] = '</a>'
4659
4804
  else
4660
- image_opts[:fit] = container_size
4805
+ alt_text_vars[:link] = ''
4806
+ alt_text_vars[:'/link'] = ''
4661
4807
  end
4662
- image_opts
4808
+ theme_font :image_alt do
4809
+ ink_prose alt_text_template % alt_text_vars, align: opts[:align], margin: 0, normalize: false, single_line: true
4810
+ end
4811
+ ink_caption node, category: :image, end: :bottom if node.title?
4812
+ theme_margin :block, :bottom, (next_enclosed_block node) unless opts[:pinned]
4813
+ nil
4663
4814
  end
4664
4815
 
4665
- # Resolves the explicit width, if specified, as a PDF pt value.
4666
- #
4667
- # Resolves the explicit width, first considering the pdfwidth attribute, then the scaledwidth
4668
- # attribute, then the theme default (if enabled by the :use_fallback option), and finally the
4669
- # width attribute. If the specified value is in pixels, the value is scaled by 75% to perform
4670
- # approximate CSS px to PDF pt conversion. If the value is a percentage, and the
4671
- # bounds_width option is given, the percentage of the bounds_width value is returned.
4672
- # Otherwise, the percentage width is returned.
4673
- #--
4674
- # QUESTION: should we enforce positive result?
4675
- def resolve_explicit_width attrs, opts = {}
4676
- bounds_width = opts[:bounds_width]
4677
- # QUESTION: should we restrict width to bounds_width for pdfwidth?
4678
- if attrs.key? 'pdfwidth'
4679
- if (width = attrs['pdfwidth']).end_with? '%'
4680
- bounds_width ? (width.to_f / 100) * bounds_width : width
4681
- elsif opts[:support_vw] && (width.end_with? 'vw')
4682
- (width.chomp 'vw').extend ViewportWidth
4683
- else
4684
- str_to_pt width
4685
- end
4686
- elsif attrs.key? 'scaledwidth'
4687
- # NOTE: the parser automatically appends % if value is unitless
4688
- if (width = attrs['scaledwidth']).end_with? '%'
4689
- bounds_width ? (width.to_f / 100) * bounds_width : width
4690
- else
4691
- str_to_pt width
4692
- end
4693
- elsif opts[:use_fallback] && (width = @theme.image_width)
4694
- if ::Numeric === width
4695
- width
4696
- elsif (width = width.to_s).end_with? '%'
4697
- bounds_width ? (width.to_f / 100) * bounds_width : bounds_width
4698
- elsif opts[:support_vw] && (width.end_with? 'vw')
4699
- (width.chomp 'vw').extend ViewportWidth
4700
- else
4701
- str_to_pt width
4702
- end
4703
- elsif attrs.key? 'width'
4704
- if (width = attrs['width']).end_with? '%'
4705
- width = (width.to_f / 100) * bounds_width if bounds_width
4706
- elsif DigitsRx.match? width
4707
- width = to_pt width.to_f, :px
4708
- else
4709
- return
4710
- end
4711
- bounds_width && opts[:constrain_to_bounds] ? [bounds_width, width].min : width
4712
- end
4816
+ def remove_tmp_files
4817
+ @tmp_files.reject! {|_, path| path ? (unlink_tmp_file path) : true }
4713
4818
  end
4714
4819
 
4715
4820
  def resolve_background_position value, default_value = {}
@@ -4744,128 +4849,50 @@ module Asciidoctor
4744
4849
  end
4745
4850
  end
4746
4851
 
4747
- def resolve_top val
4748
- if val.end_with? 'vh'
4749
- page_height * (1 - (val.to_f / 100))
4750
- elsif val.end_with? '%'
4751
- @y - effective_page_height * (val.to_f / 100)
4752
- else
4753
- @y - (str_to_pt val)
4754
- end
4852
+ def resolve_font_kerning keyword
4853
+ FontKerningTable[keyword]
4755
4854
  end
4756
4855
 
4757
- def apply_subs_discretely doc, value, opts = {}
4758
- if (imagesdir = opts[:imagesdir])
4759
- imagesdir_to_restore = doc.attr 'imagesdir'
4760
- doc.set_attr 'imagesdir', imagesdir
4761
- end
4762
- # FIXME: get sub_attributes to handle drop-line w/o a warning
4763
- doc.set_attr 'attribute-missing', 'skip' unless (attribute_missing = doc.attr 'attribute-missing') == 'skip'
4764
- value = value.gsub '\{', '\\\\\\{' if (escaped_attr_ref = value.include? '\{')
4765
- value = (subs = opts[:subs]) ? (doc.apply_subs value, subs) : (doc.apply_subs value)
4766
- value = (value.split LF).delete_if {|line| SimpleAttributeRefRx.match? line }.join LF if opts[:drop_lines_with_unresolved_attributes] && (value.include? '{')
4767
- value = value.gsub '\{', '{' if escaped_attr_ref
4768
- doc.set_attr 'attribute-missing', attribute_missing unless attribute_missing == 'skip'
4769
- if imagesdir
4770
- if imagesdir_to_restore
4771
- doc.set_attr 'imagesdir', imagesdir_to_restore
4856
+ def resolve_pagenums val
4857
+ pgnums = []
4858
+ ((val.include? ',') ? (val.split ',') : (val.split ';')).each do |entry|
4859
+ if entry.include? '..'
4860
+ from, _, to = entry.partition '..'
4861
+ pgnums += ([from.to_i, 1].max..[to.to_i, 1].max).to_a
4772
4862
  else
4773
- doc.remove_attr 'imagesdir'
4863
+ pgnums << entry.to_i
4774
4864
  end
4775
4865
  end
4776
- value
4866
+
4867
+ pgnums
4777
4868
  end
4778
4869
 
4779
- def next_enclosed_block block, descend: false
4780
- return if (context = block.context) == :document
4781
- parent_context = (parent = block.parent).context
4782
- if (list_item = context == :list_item)
4783
- return block.blocks[0] if descend && block.blocks?
4784
- siblings = parent.items
4870
+ def resolve_top val
4871
+ if val.end_with? 'vh'
4872
+ page_height * (1 - (val.to_f / 100))
4873
+ elsif val.end_with? '%'
4874
+ @y - effective_page_height * (val.to_f / 100)
4785
4875
  else
4786
- siblings = parent.blocks
4787
- end
4788
- siblings = siblings.flatten if parent_context == :dlist
4789
- if block != siblings[-1]
4790
- (self_idx = siblings.index block) && siblings[self_idx + 1]
4791
- elsif parent_context == :list_item || (parent_context == :open && parent.style != 'abstract') || parent_context == :section
4792
- next_enclosed_block parent
4793
- elsif list_item && (grandparent = parent.parent).context == :list_item
4794
- next_enclosed_block grandparent
4876
+ @y - (str_to_pt val)
4795
4877
  end
4796
4878
  end
4797
4879
 
4798
- # Deprecated method names
4799
- alias layout_footnotes ink_footnotes
4800
- alias layout_title_page ink_title_page
4801
- alias layout_cover_page ink_cover_page
4802
- alias layout_chapter_title ink_chapter_title
4803
- alias layout_part_title ink_part_title
4804
- alias layout_general_heading ink_general_heading
4805
- alias layout_heading ink_heading
4806
- alias layout_prose ink_prose
4807
- alias layout_caption ink_caption
4808
- alias layout_table_caption ink_table_caption
4809
- alias layout_toc ink_toc
4810
- alias layout_toc_level ink_toc_level
4811
- alias layout_running_content ink_running_content
4812
-
4813
- # intercepts "class CustomPDFConverter < (Asciidoctor::Converter.for 'pdf')"
4814
- def self.method_added method_sym
4815
- if (method_name = method_sym.to_s).start_with? 'layout_'
4816
- alias_method %(ink_#{method_name.slice 7, method_name.length}).to_sym, method_sym
4817
- elsif method_name == 'convert_listing_or_literal' || method_name == 'convert_code'
4818
- alias_method :convert_listing, method_sym
4819
- alias_method :convert_literal, method_sym
4880
+ def resolve_text_transform key, use_fallback = true
4881
+ if (transform = ::Hash === key ? (key.delete :text_transform) : @theme[key])
4882
+ transform == 'none' ? nil : transform
4883
+ elsif use_fallback
4884
+ @text_transform
4820
4885
  end
4821
4886
  end
4822
4887
 
4823
- # intercepts "(Asciidoctor::Converter.for 'pdf').prepend CustomConverterExtensions"
4824
- def self.prepend *mods
4825
- super
4826
- mods.each {|mod| (mod.instance_methods false).each {|method| method_added method } }
4827
- self
4828
- end
4829
-
4830
- private
4831
-
4832
- def add_link_to_image uri, image_info, image_opts
4833
- image_width = image_info[:width]
4834
- image_height = image_info[:height]
4835
-
4836
- case image_opts[:position]
4837
- when :center
4838
- image_x = bounds.left_side + (bounds.width - image_width) * 0.5
4839
- when :right
4840
- image_x = bounds.right_side - image_width
4841
- else # :left, nil
4842
- image_x = bounds.left_side
4843
- end
4844
-
4845
- case image_opts[:vposition]
4846
- when :top
4847
- image_y = bounds.absolute_top
4848
- when :center
4849
- image_y = bounds.absolute_top - (bounds.height - image_height) * 0.5
4850
- when :bottom
4851
- image_y = bounds.absolute_bottom + image_height
4888
+ # QUESTION: should we pass a category as an argument?
4889
+ # QUESTION: should we make this a method on the theme ostruct? (e.g., @theme.resolve_color key, fallback)
4890
+ def resolve_theme_color key, fallback_color = nil, transparent_color = fallback_color
4891
+ if (color = @theme[key])
4892
+ color == 'transparent' ? transparent_color : color
4852
4893
  else
4853
- image_y = y - image_opts[:vposition]
4854
- end unless (image_y = image_opts[:y])
4855
-
4856
- link_annotation [image_x, (image_y - image_height), (image_x + image_width), image_y], Border: [0, 0, 0], A: { Type: :Action, S: :URI, URI: uri.as_pdf }
4857
- end
4858
-
4859
- def load_open_uri
4860
- if @cache_uri && !(defined? ::OpenURI::Cache) && (Helpers.require_library 'open-uri/cached', 'open-uri-cached', :warn).nil?
4861
- # disable URI caching if library fails to load
4862
- @cache_uri = false
4894
+ fallback_color
4863
4895
  end
4864
- ::OpenURI
4865
- end
4866
-
4867
- def remove_tmp_files
4868
- @tmp_files.reject! {|_, path| path ? (unlink_tmp_file path) : true }
4869
4896
  end
4870
4897
 
4871
4898
  def unlink_tmp_file path
@@ -4876,6 +4903,23 @@ module Asciidoctor
4876
4903
  false
4877
4904
  end
4878
4905
 
4906
+ # Calculate the width that is needed to print all the
4907
+ # fragments without wrapping any lines.
4908
+ #
4909
+ # This method assumes endlines are represented as discrete entries in the
4910
+ # fragments array.
4911
+ def width_of_fragments fragments
4912
+ line_widths = [0]
4913
+ fragments.each do |fragment|
4914
+ if fragment.text == LF
4915
+ line_widths << 0
4916
+ else
4917
+ line_widths[-1] += fragment.width
4918
+ end
4919
+ end
4920
+ line_widths.max
4921
+ end
4922
+
4879
4923
  # Promotes author to primary author attributes around block; restores original attributes after block executes
4880
4924
  def with_author doc, author, primary
4881
4925
  doc.remove_attr 'url' if (original_url = doc.attr 'url')
@@ -4908,53 +4952,7 @@ module Asciidoctor
4908
4952
  result
4909
4953
  end
4910
4954
 
4911
- # NOTE: assume URL is escaped (i.e., contains character references such as &amp;)
4912
- def breakable_uri uri
4913
- scheme, address = uri.split UriSchemeBoundaryRx, 2
4914
- address, scheme = scheme, address unless address
4915
- unless address.nil_or_empty?
4916
- address = address.gsub UriBreakCharsRx, UriBreakCharRepl
4917
- # NOTE: require at least two characters after a break
4918
- address.slice!(-2) if address[-2] == ZeroWidthSpace
4919
- end
4920
- %(#{scheme}#{address})
4921
- end
4922
-
4923
- def consolidate_ranges nums
4924
- if nums.size > 1
4925
- prev = nil
4926
- nums.each_with_object [] do |num, accum|
4927
- if prev && (prev.to_i + 1) == num.to_i
4928
- accum[-1][1] = num
4929
- else
4930
- accum << [num]
4931
- end
4932
- prev = num
4933
- end.map {|range| range.join '-' }
4934
- else
4935
- nums
4936
- end
4937
- end
4938
-
4939
- def resolve_pagenums val
4940
- pgnums = []
4941
- ((val.include? ',') ? (val.split ',') : (val.split ';')).each do |entry|
4942
- if entry.include? '..'
4943
- from, _, to = entry.partition '..'
4944
- pgnums += ([from.to_i, 1].max..[to.to_i, 1].max).to_a
4945
- else
4946
- pgnums << entry.to_i
4947
- end
4948
- end
4949
-
4950
- pgnums
4951
- end
4952
-
4953
- def get_char code
4954
- (code.start_with? '\u') ? ([((code.slice 2, code.length).to_i 16)].pack 'U1') : code
4955
- end
4956
-
4957
- def create_prototype
4955
+ def create_scratch_prototype
4958
4956
  @label = :scratch
4959
4957
  @save_state = nil
4960
4958
  @scratch_depth = 0
@@ -4971,7 +4969,7 @@ module Asciidoctor
4971
4969
  end
4972
4970
 
4973
4971
  def init_scratch originator
4974
- @prototype = originator.instance_variable_get :@prototype
4972
+ @scratch_prototype = originator.instance_variable_get :@scratch_prototype
4975
4973
  @tmp_files = originator.instance_variable_get :@tmp_files
4976
4974
  text_formatter.scratch = true
4977
4975
  self
@@ -4996,7 +4994,7 @@ module Asciidoctor
4996
4994
 
4997
4995
  def clear_scratch
4998
4996
  @scratch_depth = 0
4999
- @save_state = @prototype = @scratch = nil
4997
+ @save_state = @scratch_prototype = @scratch = nil
5000
4998
  end
5001
4999
  end
5002
5000
  end