Skip to content

Commit

Permalink
resolves #353 add support for image float to converter (PR #2147)
Browse files Browse the repository at this point in the history
  • Loading branch information
mojavelinux authored May 14, 2022
1 parent 508ab3f commit 77393d3
Show file tree
Hide file tree
Showing 15 changed files with 1,142 additions and 26 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ For a detailed view of what has changed, refer to the {url-repo}/commits/main[co

Enhancements::

* add support for float attribute on image; wrap ensuing paragraphs around image with `float` attribute (#353)
* add `supports_float_wrapping?` method for extended converter to override to enlist other blocks in float wrapping; add example to docs (#353)
* add `image-float-gap` key to theme to control space around image float (#353)
* add support for `text-transform` property on first line of abstract in theme (#2141)
* rename `resolve_alignment_from_role` to `resolve_text_align_from_role` to reflect proper terminology and purpose; alias old method name
* add support for orphan avoidance to discrete headings to match behavior of section titles (using call to `arrange_heading`) (#2151)
* rename `arrange_section` to `arrange_heading` to reflect proper terminology and purpose
* add `index-column-gap` key to theme to control size of gap between columns
* coerce `image-caption-max-width` to `fit-content` if `float` attribute is set on block image (#2150)
* add support for text box with fixed height via `:height` option to `typeset_text` helper
* configure `typeset_text` and `ink_prose` to return remaining fragments when `:height` option is specified
* add support for `:indent_paragraphs` option to formatted text box (#353)
* if `float` attribute is set on block image, set max width on caption to `fit-content` if max width not already set to a `fit-content` value

Improvements::

Expand Down
13 changes: 13 additions & 0 deletions docs/modules/extend/examples/pdf-converter-code-float-wrapping.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class PDFConverterCodeFloatWrapping < (Asciidoctor::Converter.for 'pdf')
register_for 'pdf'

def supports_float_wrapping? node
%i(paragraph listing literal).include? node.context
end

def convert_code node
return super unless (float_box = @float_box ||= nil)
indent(float_box[:left] - bounds.left, bounds.width - float_box[:right]) { super }
@float_box = nil unless page_number == float_box[:page] && cursor > float_box[:bottom]
end
end
25 changes: 25 additions & 0 deletions docs/modules/extend/pages/use-cases.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,31 @@ image:
indent: [0.5in, 0]
----

== Wrap code blocks around an image float

Asciidoctor PDF provides basic support for image floats.
It will wrap paragraph text on the opposing side of the float.
However, if it encounters a non-paragraph, the converter will clear the float and continue positioning content below the image.

As a companion to this basics support, the converter provides a framework for broadening support for float wrapping.
We can take advantage of this framework in an extended converter.
By extending the converter and overriding the `supports_float_wrapping?` as well as the handler for the block you want to enlist (e.g., `convert_code`), you can arrange additional content into the empty space adjacent to the floated image.
In the following example, code (listing and literal) blocks are included in the float wrapping.

.Extended converter that additionally wraps code blocks around an image float
[,ruby]
----
include::example$pdf-converter-code-float-wrapping.rb[]
----

You can configure the gap next to and below the image using the `image-float-gap` key in the theme.

[,yaml]
----
image:
float-gap: [12, 6]
----

== Resources

To find even more examples of how to override the behavior of the converter, refer to the extended converter in the {url-infoq-template}[InfoQ Mini-Book template^].
7 changes: 7 additions & 0 deletions docs/modules/theme/pages/block-image.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ image:
image:
border-width: 0.5

|float-gap
|xref:measurement-units.adoc[Measurement] {vbar} xref:measurement-units.adoc[Measurement[side,bottom\]] +
(default: `[12, 6]`)
|[source]
image:
float-gap: 12

|<<width,width>>
|xref:measurement-units.adoc[Measurement] +
(default: _not set_)
Expand Down
3 changes: 1 addition & 2 deletions examples/chronicles-example.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ To leave now would mean *code dismemberment and certain death*.
Behold -> the horror!

.Wolpertinger, stuffed
[.left.thumb]
image::wolpertinger.jpg[Wolpertinger,pdfwidth=50%,link={uri-wolpertinger}]
image::wolpertinger.jpg[Wolpertinger,pdfwidth=50%,link={uri-wolpertinger},float=left,role=thumb]

(((Wolpertinger)))
(((Ravenous Beast,Wolpertinger)))
Expand Down
Binary file modified examples/chronicles-example.pdf
Binary file not shown.
136 changes: 113 additions & 23 deletions lib/asciidoctor/pdf/converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class Converter < ::Prawn::Document
TextAlignmentRoles = %w(text-justify text-left text-center text-right)
TextDecorationStyleTable = { 'underline' => :underline, 'line-through' => :strikethrough }
FontKerningTable = { 'normal' => true, 'none' => false }
BlockFloatNames = %w(left right)
BlockAlignmentNames = %w(left center right)
(AlignmentTable = { '<' => :left, '=' => :center, '>' => :right }).default = :left
ColumnPositions = [:left, :center, :right]
Expand Down Expand Up @@ -838,38 +839,39 @@ def convert_preamble node

def convert_paragraph node
add_dest_for_block node if node.id

prose_opts = { margin_bottom: 0, hyphenate: true }
if (align = resolve_text_align_from_role (roles = node.roles), query_theme: true, remove_predefined: true)
prose_opts[:align] = align
end

role_keys = roles.map {|role| %(role_#{role}).to_sym } unless roles.empty?
if (text_indent = @theme.prose_text_indent) > 0 ||
((text_indent = @theme.prose_text_indent_inner) > 0 &&
(self_idx = (siblings = node.parent.blocks).index node) > 0 && siblings[self_idx - 1].context == :paragraph)
prose_opts[:indent_paragraphs] = text_indent
end

# TODO: check if we're within one line of the bottom of the page
# and advance to the next page if so (similar to logic for section titles)
ink_caption node, labeled: false if node.title?

if (bottom_gutter = @bottom_gutters[-1][node])
prose_opts[:bottom_gutter] = bottom_gutter
end

if roles.empty?
ink_prose node.content, prose_opts
else
theme_font_cascade (roles.map {|role| %(role_#{role}).to_sym }) do
ink_prose node.content, prose_opts
block_next = next_enclosed_block node

insert_margin_bottom = proc do
if (margin_inner_val = @theme.prose_margin_inner) && block_next&.context == :paragraph
margin_bottom margin_inner_val
else
theme_margin :prose, :bottom, block_next
end
end

block_next = next_enclosed_block node
if (margin_inner_val = @theme.prose_margin_inner) && block_next&.context == :paragraph
margin_bottom margin_inner_val
if (float_box = (@float_box ||= nil))
ink_paragraph_in_float_box node, float_box, prose_opts, role_keys, block_next, insert_margin_bottom
else
theme_margin :prose, :bottom, block_next
# TODO: check if we're within one line of the bottom of the page
# and advance to the next page if so (similar to logic for section titles)
ink_caption node, labeled: false if node.title?
role_keys ? theme_font_cascade(role_keys) { ink_prose node.content, prose_opts } : (ink_prose node.content, prose_opts)
insert_margin_bottom.call
end
end

Expand Down Expand Up @@ -1570,25 +1572,29 @@ def convert_image node, opts = {}

return on_image_error :missing, node, target, opts unless image_path

alignment = (alignment = node.attr 'align') ?
((BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left) :
(resolve_text_align_from_role node.roles) || @theme.image_align&.to_sym || :left
if (float_to = node.attr 'float') && ((BlockFloatNames.include? float_to) ? float_to : (float_to = nil))
alignment = float_to.to_sym
elsif (alignment = node.attr 'align')
alignment = (BlockAlignmentNames.include? alignment) ? alignment.to_sym : :left
else
alignment = (resolve_text_align_from_role node.roles) || @theme.image_align&.to_sym || :left
end
# TODO: support cover (aka canvas) image layout using "canvas" (or "cover") role
width = resolve_explicit_width node.attributes, bounds_width: (available_w = bounds.width), support_vw: true, use_fallback: true, constrain_to_bounds: true
# TODO: add `to_pt page_width` method to ViewportWidth type
width = (width.to_f / 100) * page_width if ViewportWidth === width

caption_end = @theme.image_caption_end&.to_sym || :bottom
caption_max_width = @theme.image_caption_max_width
caption_max_width = 'fit-content' if (node.attr? 'float') && !(caption_max_width&.start_with? 'fit-content')
caption_max_width = 'fit-content' if float_to && !(caption_max_width&.start_with? 'fit-content')
# NOTE: if width is not set explicitly and max-width is fit-content, caption height may not be accurate
caption_h = node.title? ? (ink_caption node, category: :image, end: caption_end, block_align: alignment, block_width: width, max_width: caption_max_width, dry_run: true, force_top_margin: caption_end == :bottom) : 0

align_to_page = node.option? 'align-to-page'
pinned = opts[:pinned]

begin
rendered_w = nil
rendered_h = rendered_w = nil
span_page_width_if align_to_page do
if image_format == 'svg'
if ::Base64 === image_path
Expand Down Expand Up @@ -1667,13 +1673,37 @@ def convert_image node, opts = {}
end
end
ink_caption node, category: :image, end: :bottom, block_align: alignment, block_width: rendered_w, max_width: caption_max_width if caption_end == :bottom && node.title?
theme_margin :block, :bottom, (next_enclosed_block node) unless pinned
if !pinned && (block_next = next_enclosed_block node)
if float_to && (supports_float_wrapping? block_next) && rendered_w < bounds.width
init_float_box node, rendered_w, rendered_h + caption_h, float_to
else
theme_margin :block, :bottom, block_next
end
end
rescue => e
raise if ::StopIteration === e
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' : ''}))
end
end

def supports_float_wrapping? node
node.context == :paragraph
end

def init_float_box _node, block_width, block_height, float_to
gap = ::Array === (gap = @theme.image_float_gap) ? gap.dup : [gap, gap]
float_w = block_width + (gap[0] ||= 12)
float_h = block_height + (gap[1] ||= 6)
box_l = bounds.left + (float_to == 'right' ? 0 : float_w)
box_t = cursor + block_height
box_w = bounds.width - float_w
box_r = box_l + box_w
box_h = [box_t, float_h].min
box_b = box_t - box_h
move_cursor_to box_t
@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 }
end

def draw_image_border top, w, h, alignment
if (Array @theme.image_border_width).any? {|it| it&.> 0 } && (@theme.image_border_color || @theme.base_border_color)
if (@theme.image_border_fit || 'content') == 'auto'
Expand Down Expand Up @@ -3055,6 +3085,64 @@ def ink_heading string, opts = {}
end
end

# private
def ink_paragraph_in_float_box node, float_box, prose_opts, role_keys, block_next, insert_margin_bottom
@float_box = para_font_descender = para_font_size = end_cursor = nil
if role_keys
line_metrics = theme_font_cascade role_keys do
para_font_descender = font.descender
para_font_size = font_size
calc_line_metrics @base_line_height
end
else
para_font_descender = font.descender
para_font_size = font_size
line_metrics = calc_line_metrics @base_line_height
end
# allocate the space of at least one empty line below block
line_height_length = line_metrics.height + line_metrics.leading + line_metrics.padding_top
start_page_number = float_box[:page]
start_cursor = cursor
block_bottom = (float_box_bottom = float_box[:bottom]) + float_box[:gap][1]
# use :at to incorporate padding top from line metrics
# use :final_gap to incorporate padding bottom from line metrics
# use :draw_text_callback to track end cursor (requires applying :final_gap to result manually)
prose_opts.update \
at: [float_box[:left], start_cursor - line_metrics.padding_top],
width: float_box[:width],
height: [cursor, float_box[:height] - (float_box[:top] - start_cursor) + line_height_length].min,
final_gap: para_font_descender + line_metrics.padding_bottom,
draw_text_callback: (proc do |text, opts|
draw_text! text, opts
end_cursor = opts[:at][1] # does not include :final_gap value
end)
overflow_text = role_keys ?
theme_font_cascade(role_keys) { ink_prose node.content, prose_opts } :
(ink_prose node.content, prose_opts)
move_cursor_to end_cursor -= prose_opts[:final_gap] if end_cursor # ink_prose with :height does not move cursor
if overflow_text.empty?
if block_next && (supports_float_wrapping? block_next)
insert_margin_bottom.call
@float_box = float_box if page_number == start_page_number && cursor > start_cursor - prose_opts[:height]
elsif end_cursor > block_bottom
move_cursor_to block_bottom
theme_margin :block, :bottom, block_next
else
insert_margin_bottom.call
end
else
overflow_prose_opts = { align: prose_opts[:align] || @base_text_align.to_sym }
unless end_cursor
overflow_prose_opts[:indent_paragraphs] = prose_opts[:indent_paragraphs]
move_cursor_to float_box_bottom if start_cursor > float_box_bottom
end
role_keys ?
theme_font_cascade(role_keys) { typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts } :
(typeset_formatted_text overflow_text, line_metrics, overflow_prose_opts)
insert_margin_bottom.call
end
end

# NOTE: inline_format is true by default
def ink_prose string, opts = {}
top_margin = (margin = (opts.delete :margin)) || (opts.delete :margin_top) || 0
Expand All @@ -3077,12 +3165,13 @@ def ink_prose string, opts = {}
text_decoration_width: (opts.delete :text_decoration_width),
}.compact
end
typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
result = typeset_text string, (calc_line_metrics (opts.delete :line_height) || @base_line_height), {
color: @font_color,
inline_format: [inline_format_opts],
align: @base_text_align.to_sym,
}.merge(opts)
margin_bottom bot_margin
result
end

def generate_manname_section node
Expand Down Expand Up @@ -4194,9 +4283,10 @@ def rendered_width_of_char char, opts = {}

# TODO: document me, esp the first line formatting functionality
def typeset_text string, line_metrics, opts = {}
move_down line_metrics.padding_top
opts = { leading: line_metrics.leading, final_gap: line_metrics.final_gap }.merge opts
string = string.gsub CjkLineBreakRx, ZeroWidthSpace if @cjk_line_breaks
return text_box string, opts if opts[:height]
move_down line_metrics.padding_top
if (hanging_indent = (opts.delete :hanging_indent) || 0) > 0
indent hanging_indent do
text string, (opts.merge indent_paragraphs: -hanging_indent)
Expand Down
1 change: 1 addition & 0 deletions lib/asciidoctor/pdf/ext/prawn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
require_relative 'prawn/formatted_text/arranger'
require_relative 'prawn/formatted_text/box'
require_relative 'prawn/formatted_text/fragment'
require_relative 'prawn/formatted_text/indented_paragraph_wrap'
require_relative 'prawn/formatted_text/protect_bottom_gutter'
require_relative 'prawn/extensions'
3 changes: 2 additions & 1 deletion lib/asciidoctor/pdf/ext/prawn/extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -468,10 +468,11 @@ def text_with_formatted_first_line string, first_line_options, options
end
first_line_text_transform = first_line_options.delete :text_transform
options = options.merge document: self
text_indent = options.delete :indent_paragraphs
# QUESTION: should we merge more carefully here? (hand-select keys?)
first_line_options = (options.merge first_line_options).merge single_line: true, first_line: true
box = ::Prawn::Text::Formatted::Box.new fragments, first_line_options
if (text_indent = options.delete :indent_paragraphs)
if text_indent
remaining_fragments = indent text_indent do
box.render dry_run: true
end
Expand Down
1 change: 1 addition & 0 deletions lib/asciidoctor/pdf/ext/prawn/formatted_text/box.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def initialize formatted_text, options = {}
super
formatted_text[0][:normalize_line_height] = true if options[:normalize_line_height] && !formatted_text.empty?
options[:extensions]&.each {|extension| extend extension }
extend Prawn::Text::Formatted::IndentedParagraphWrap if (@indent_paragraphs = options[:indent_paragraphs])
if (bottom_gutter = options[:bottom_gutter]) && bottom_gutter > 0
@bottom_gutter = bottom_gutter
extend Prawn::Text::Formatted::ProtectBottomGutter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module Prawn::Text::Formatted
module IndentedParagraphWrap
# Override Prawn::Text::Formatted::Box#wrap method to add support for :indent_paragraphs to (formatted_)text_box.
def wrap array
initialize_wrap array
stop = nil
until stop
if (first_line_indent = @indent_paragraphs) && @printed_lines.empty?
@width -= first_line_indent
stop = @document.indent(first_line_indent) { wrap_and_print_line }
@width += first_line_indent
else
stop = wrap_and_print_line
end
end
@text = @printed_lines.join ?\n
@everything_printed = @arranger.finished?
@arranger.unconsumed
end

def wrap_and_print_line
@line_wrap.wrap_line \
document: @document,
kerning: @kerning,
width: @width,
arranger: @arranger,
disable_wrap_by_char: @disable_wrap_by_char
if enough_height_for_this_line?
move_baseline_down
print_line
@single_line || @arranger.finished?
else
true
end
end
end
end
5 changes: 5 additions & 0 deletions spec/fixtures/lorem-ipsum.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
2-sentences-1-paragraph: |-
Lorem ipsum dolor sit amet consectetur adipiscing elit conubia turpis, elementum porttitor sagittis pellentesque dictumst egestas ante praesent.
Magna velit egestas quam sociis placerat facilisis felis mauris, primis ridiculus commodo scelerisque eleifend morbi non.
4-sentences-1-paragraph: |-
Lorem ipsum dolor sit amet consectetur adipiscing elit conubia turpis, elementum porttitor sagittis pellentesque dictumst egestas ante praesent.
Magna velit egestas quam sociis placerat facilisis felis mauris, primis ridiculus commodo scelerisque eleifend morbi non.
Blandit nam porta arcu sociosqu eros libero cras morbi, enim tempus est elementum ut interdum accumsan, hendrerit ornare imperdiet proin vivamus inceptos fames mi, sociis leo odio donec mattis hac.
Condimentum egestas velit accumsan lobortis montes quisque mattis curae placerat magna, primis justo tristique elementum facilisis penatibus pretium in scelerisque est, euismod tempor luctus tincidunt sed potenti enim ac ut.
2-sentences-2-paragraphs: |-
Lorem ipsum dolor sit amet consectetur adipiscing elit conubia turpis, elementum porttitor sagittis pellentesque dictumst egestas ante praesent.
Expand Down
Binary file added spec/fixtures/rect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 77393d3

Please sign in to comment.