# frozen_string_literal: true # text/formatted/rectangle.rb : Implements text boxes with formatted text # # Copyright February 2010, Daniel Nelson. All Rights Reserved. # # This is free software. Please see the LICENSE and COPYING files for details. # module Prawn module Text module Formatted # Formatted text box. # # Generally, one would use the {Prawn::Text::Formatted#formatted_text_box} # convenience method. However, using `Text::Formatted::Box.new` in # conjunction with `#render(dry_run: true)` enables one to do calculations # prior to placing text on the page, or to determine how much vertical # space was consumed by the printed text class Box include Prawn::Text::Formatted::Wrap # @group Experimental API # The text that was successfully printed (or, if `:dry_run` was # used, the text that would have been successfully printed). # @return [Array] attr_reader :text # True if nothing printed (or, if `:dry_run` was used, nothing would # have been successfully printed). # # @return [Boolean] def nothing_printed? @nothing_printed end # True if everything printed (or, if `:dry_run` was used, everything # would have been successfully printed). # # @return [Boolean] def everything_printed? @everything_printed end # The upper left corner of the text box. # @return [Array(Number, Number)] attr_reader :at # The line height of the last line printed. # @return [Number] attr_reader :line_height # The height of the ascender of the last line printed. # @return [Number] attr_reader :ascender # The height of the descender of the last line printed. # @return [Number] attr_reader :descender # The leading used during printing. # @return [Number] attr_reader :leading # Gap between adjacent lines of text. # # @return [Number] def line_gap line_height - (ascender + descender) end # See Prawn::Text#text_box for valid options # # @param formatted_text [Array any}>] # Formatted text is an array of hashes, where each hash defines text # and format information. The following hash options are supported: # # - `:text` --- the text to format according to the other hash # options. # - `:styles` --- an array of styles to apply to this text. Available # styles include `:bold`, `:italic`, `:underline`, `:strikethrough`, # `:subscript`, and `:superscript`. # - `:size` ---a number denoting the font size to apply to this text. # - `:character_spacing` --- a number denoting how much to increase or # decrease the default spacing between characters. # - `:font` --- the name of a font. The name must be an AFM font with # the desired faces or must be a font that is already registered # using {Prawn::Document#font_families}. # - `:color` --- anything compatible with # {Prawn::Graphics::Color#fill_color} and # {Prawn::Graphics::Color#stroke_color}. # - :link` --- a URL to which to create a link. A clickable link will # be created to that URL. Note that you must explicitly underline # and color using the appropriate tags if you which to draw # attention to the link. # - `:anchor` --- a destination that has already been or will be # registered using `PDF::Core::Destinations#add_dest`. A clickable # link will be created to that destination. Note that you must # explicitly underline and color using the appropriate tags if you # which to draw attention to the link. # - `:local` --- a file or application to be opened locally. # A clickable link will be created to the provided local file or # application. If the file is another PDF, it will be opened in # a new window. Note that you must explicitly underline and color # using the appropriate options if you which to draw attention to # the link. # - `:draw_text_callback` --- if provided, this Proc will be called # instead of {Prawn::Document#draw_text!} once per fragment for # every low-level addition of text to the page. # - `:callback` --- an object (or array of such objects) with two # methods: `render_behind` and `render_in_front`, which are called # immediately prior to and immediately after rendering the text # fragment and which are passed the fragment as an argument. # @param options [Hash{Symbol => any}] # @option options :document [Prawn::Document] Owning document. # @option options :kerning [Boolean] # (value of document.default_kerning?) # Whether or not to use kerning (if it is available with the current # font). # @option options :size [Number] (current font size) # The font size to use. # @option options :character_spacing [Number] (0) # The amount of space to add to or remove from the default character # spacing. # @option options :disable_wrap_by_char [Boolean] (false) # Whether or not to prevent mid-word breaks when text does not fit in # box. # @option options :mode [Symbol] (:fill) # The text rendering mode. See documentation for # {Prawn::Document#text_rendering_mode} for a list of valid options. # @option option :style [Symbol] (current style) # The style to use. The requested style must be part of the current # font family. # @option option :at [Array(Number, Number)] (bounds top left corner) # The upper left corner of the box. # @option options :width [Number] (bounds.right - at[0]) # The width of the box. # @option options :height [Number] (default_height()) # The height of the box. # @option options :direction [:ltr, :rtl] # (value of document.text_direction) # Direction of the text (left-to-right or right-to-left). # @option options :fallback_fonts [Array] # An array of font names. Each name must be the name of an AFM font or # the name that was used to register a family of external fonts (see # {Prawn::Document#font_families}). If present, then each glyph will # be rendered using the first font that includes the glyph, starting # with the current font and then moving through `:fallback_fonts`. # @option options :align [:left, :center, :right, :justify] # (:left if direction is :ltr, :right if direction is :rtl) # Alignment within the bounding box. # @option options :valign [:top, :center, :bottom] (:top) # Vertical alignment within the bounding box. # @option options :rotate [Number] # The angle to rotate the text. # @option options :rotate_around # [:center, :upper_left, :upper_right, :lower_right, :lower_left] # (:upper_left) # The point around which to rotate the text. # @option options :leading [Number] (value of document.default_leading) # Additional space between lines. # @option options :single_line [Boolean] (false) # If true, then only the first line will be drawn. # @option options :overflow [:truncate, :shrink_to_fit, :expand] # (:truncate) # This controls the behavior when the amount of text exceeds the # available space. # @option options :min_font_size [Number] (5) # The minimum font size to use when `:overflow` is set to # `:shrink_to_fit` (that is the font size will not be reduced to less # than this value, even if it means that some text will be cut off). def initialize(formatted_text, options = {}) @inked = false Prawn.verify_options(valid_options, options) options = options.dup self.class.extensions.reverse_each { |e| extend(e) } @overflow = options[:overflow] || :truncate @disable_wrap_by_char = options[:disable_wrap_by_char] self.original_text = formatted_text @text = nil @document = options[:document] @direction = options[:direction] || @document.text_direction @fallback_fonts = options[:fallback_fonts] || @document.fallback_fonts @at = ( options[:at] || [@document.bounds.left, @document.bounds.top] ).dup @width = options[:width] || (@document.bounds.right - @at[0]) @height = options[:height] || default_height @align = options[:align] || (@direction == :rtl ? :right : :left) @vertical_align = options[:valign] || :top @leading = options[:leading] || @document.default_leading @character_spacing = options[:character_spacing] || @document.character_spacing @mode = options[:mode] || @document.text_rendering_mode @rotate = options[:rotate] || 0 @rotate_around = options[:rotate_around] || :upper_left @single_line = options[:single_line] @draw_text_callback = options[:draw_text_callback] # if the text rendering mode is :unknown, force it back to :fill if @mode == :unknown @mode = :fill end if @overflow == :expand # if set to expand, then we simply set the bottom # as the bottom of the document bounds, since that # is the maximum we should expand to @height = default_height @overflow = :truncate end @min_font_size = options[:min_font_size] || 5 if options[:kerning].nil? options[:kerning] = @document.default_kerning? end @options = { kerning: options[:kerning], size: options[:size], style: options[:style], } super(formatted_text, options) end # Render text to the document based on the settings defined in # constructor. # # In order to facilitate look-ahead calculations, this method accepts # a `dry_run: true` option. If provided, then everything is executed as # if rendering, with the exception that nothing is drawn on the page. # Useful for look-ahead computations of height, unprinted text, etc. # # @param flags [Hash{Symbol => any}] # @option flags :dry_run [Boolean] (false) # Do not draw the text. Everything else is done. # @return [Array] # A formatted text array representing any text that did not print # under the current settings. # @raise [Prawn::Text::Formatted::Arranger::BadFontFamily] # If no font family is defined for the current font. # @raise [Prawn::Errors::CannotFit] # If not wide enough to print any text. def render(flags = {}) unprinted_text = [] @document.save_font do @document.character_spacing(@character_spacing) do @document.text_rendering_mode(@mode) do process_options text = normalized_text(flags) @document.font_size(@font_size) do shrink_to_fit(text) if @overflow == :shrink_to_fit process_vertical_alignment(text) @inked = true unless flags[:dry_run] unprinted_text = if @rotate != 0 && @inked render_rotated(text) else wrap(text) end @inked = false end end end end unprinted_text.map do |e| e.merge(text: @document.font.to_utf8(e[:text])) end end # The width available at this point in the box. # # @return [Number] def available_width @width end # The height actually used during the previous {render}. # # @return [Number] def height return 0 if @baseline_y.nil? || @descender.nil? (@baseline_y - @descender).abs end # @private # @param fragment [Prawn::Text::Formatted::Fragment] # @param accumulated_width [Number] # @param line_width [Number] # @param word_spacing [Number] # @return [void] def draw_fragment( fragment, accumulated_width = 0, line_width = 0, word_spacing = 0 ) case @align when :left x = @at[0] when :center x = @at[0] + (@width * 0.5) - (line_width * 0.5) when :right x = @at[0] + @width - line_width when :justify x = if @direction == :ltr @at[0] else @at[0] + @width - line_width end else raise ArgumentError, 'align must be one of :left, :right, :center or :justify symbols' end x += accumulated_width y = @at[1] + @baseline_y y += fragment.y_offset fragment.left = x fragment.baseline = y if @inked draw_fragment_underlays(fragment) @document.word_spacing(word_spacing) do if @draw_text_callback @draw_text_callback.call( fragment.text, at: [x, y], kerning: @kerning, ) else @document.draw_text!( fragment.text, at: [x, y], kerning: @kerning, ) end end draw_fragment_overlays(fragment) end end # @group Extension API # Text box extensions. # # Example: # # ```ruby # module MyWrap # def wrap(array) # initialize_wrap([{ text: 'all your base are belong to us' }]) # @line_wrap.wrap_line( # document: @document, # kerning: @kerning, # width: 10000, # arranger: @arranger # ) # fragment = @arranger.retrieve_fragment # format_and_draw_fragment(fragment, 0, @line_wrap.width, 0) # [] # end # end # # Prawn::Text::Formatted::Box.extensions << MyWrap # # box = Prawn::Text::Formatted::Box.new('hello world') # box.render("why can't I print anything other than" + # '"all your base are belong to us"?') # ``` # # See {Prawn::Text::Formatted::Wrap} for what is required of the # wrap method if you want to override the default wrapping algorithm. # # @return [Array] def self.extensions @extensions ||= [] end # @private def self.inherited(base) super extensions.each { |e| base.extensions << e } end # @private def valid_options PDF::Core::Text::VALID_OPTIONS + %i[ at height width align valign rotate rotate_around overflow min_font_size disable_wrap_by_char leading character_spacing mode single_line document direction fallback_fonts draw_text_callback ] end private def normalized_text(flags) text = normalize_encoding text.each { |t| t.delete(:color) } if flags[:dry_run] text end def original_text @original_array.map(&:dup) end def original_text=(formatted_text) @original_array = formatted_text end def normalize_encoding formatted_text = original_text unless @fallback_fonts.empty? formatted_text = process_fallback_fonts(formatted_text) end formatted_text.each do |hash| if hash[:font] @document.font(hash[:font]) do hash[:text] = @document.font.normalize_encoding(hash[:text]) end else hash[:text] = @document.font.normalize_encoding(hash[:text]) end end formatted_text end def process_fallback_fonts(formatted_text) modified_formatted_text = [] formatted_text.each do |hash| fragments = analyze_glyphs_for_fallback_font_support(hash) modified_formatted_text.concat(fragments) end modified_formatted_text end def analyze_glyphs_for_fallback_font_support(hash) font_glyph_pairs = [] original_font = @document.font.family fragment_font = hash[:font] || original_font fragment_font_options = (fragment_font_style = font_style(hash[:styles])) == :normal ? {} : { style: fragment_font_style } fallback_fonts = @fallback_fonts.dup # always default back to the current font if the glyph is missing from # all fonts fallback_fonts << fragment_font @document.save_font do hash[:text].each_char do |char| font_glyph_pairs << [ find_font_for_this_glyph( char, fragment_font, fallback_fonts.dup, fragment_font_options, ), char, ] end end # Don't add a :font to fragments if it wasn't there originally if hash[:font].nil? font_glyph_pairs.each do |pair| pair[0] = nil if pair[0] == original_font end end form_fragments_from_like_font_glyph_pairs(font_glyph_pairs, hash) end def font_style(styles) if styles if styles.include?(:bold) styles.include?(:italic) ? :bold_italic : :bold elsif styles.include?(:italic) :italic else :normal end else :normal end end def find_font_for_this_glyph(char, current_font, fallback_fonts, current_font_options = {}) @document.font(current_font, current_font_options) if fallback_fonts.empty? || @document.font.glyph_present?(char) current_font else find_font_for_this_glyph(char, fallback_fonts.shift, fallback_fonts, current_font_options) end end def form_fragments_from_like_font_glyph_pairs(font_glyph_pairs, hash) fragments = [] fragment = nil current_font = nil font_glyph_pairs.each do |font, char| if font != current_font || fragments.count.zero? current_font = font fragment = hash.dup fragment[:text] = char fragment[:font] = font unless font.nil? fragments << fragment else fragment[:text] += char end end fragments end def move_baseline_down if @baseline_y.zero? @baseline_y = -@ascender else @baseline_y -= (@line_height + @leading) end end # Returns the default height to be used if none is provided or if the # overflow option is set to :expand. If we are in a stretchy bounding # box, assume we can stretch to the bottom of the innermost non-stretchy # box. # def default_height # Find the "frame", the innermost non-stretchy bbox. frame = @document.bounds frame = frame.parent while frame.stretchy? && frame.parent @at[1] + @document.bounds.absolute_bottom - frame.absolute_bottom end def process_vertical_alignment(text) # The vertical alignment must only be done once per text box, but # we need to wait until render() is called so that the fonts are set # up properly for wrapping. So guard with a boolean to ensure this is # only run once. if defined?(@vertical_alignment_processed) && @vertical_alignment_processed return end @vertical_alignment_processed = true return if @vertical_align == :top wrap(text) case @vertical_align when :center @at[1] -= (@height - height + @descender) * 0.5 when :bottom @at[1] -= (@height - height) else raise ArgumentError, 'valign must be one of :left, :right or :center symbols' end @height = height end # Decrease the font size until the text fits or the min font # size is reached def shrink_to_fit(text) loop do if @disable_wrap_by_char && @font_size > @min_font_size begin wrap(text) rescue Errors::CannotFit # Ignore errors while we can still attempt smaller # font sizes. end else wrap(text) end break if @everything_printed || @font_size <= @min_font_size @font_size = [@font_size - 0.5, @min_font_size].max @document.font_size = @font_size end end def process_options # must be performed within a save_font block because # document.process_text_options sets the font @document.process_text_options(@options) @font_size = @options[:size] @kerning = @options[:kerning] end def render_rotated(text) unprinted_text = '' case @rotate_around when :center x = @at[0] + (@width * 0.5) y = @at[1] - (@height * 0.5) when :upper_right x = @at[0] + @width y = @at[1] when :lower_right x = @at[0] + @width y = @at[1] - @height when :lower_left x = @at[0] y = @at[1] - @height else x = @at[0] y = @at[1] end @document.rotate(@rotate, origin: [x, y]) do unprinted_text = wrap(text) end unprinted_text end def draw_fragment_underlays(fragment) fragment.callback_objects.each do |obj| obj.render_behind(fragment) if obj.respond_to?(:render_behind) end end def draw_fragment_overlays(fragment) draw_fragment_overlay_styles(fragment) draw_fragment_overlay_link(fragment) draw_fragment_overlay_anchor(fragment) draw_fragment_overlay_local(fragment) fragment.callback_objects.each do |obj| obj.render_in_front(fragment) if obj.respond_to?(:render_in_front) end end def draw_fragment_overlay_link(fragment) return unless fragment.link box = fragment.absolute_bounding_box @document.link_annotation( box, Border: [0, 0, 0], A: { Type: :Action, S: :URI, URI: PDF::Core::LiteralString.new(fragment.link), }, ) end def draw_fragment_overlay_anchor(fragment) return unless fragment.anchor box = fragment.absolute_bounding_box @document.link_annotation( box, Border: [0, 0, 0], Dest: fragment.anchor, ) end def draw_fragment_overlay_local(fragment) return unless fragment.local box = fragment.absolute_bounding_box @document.link_annotation( box, Border: [0, 0, 0], A: { Type: :Action, S: :Launch, F: PDF::Core::LiteralString.new(fragment.local), NewWindow: true, }, ) end def draw_fragment_overlay_styles(fragment) if fragment.styles.include?(:underline) @document.stroke_line(fragment.underline_points) end if fragment.styles.include?(:strikethrough) @document.stroke_line(fragment.strikethrough_points) end end end end end end