# frozen_string_literal: true Prawn::Font::AFM.instance_variable_set :@hide_m17n_warning, true require 'prawn/icon' Prawn::Icon::Compatibility.send :prepend, (::Module.new { def warning *args; end }) module Asciidoctor module Prawn module Extensions include ::Asciidoctor::PDF::Measurements include ::Asciidoctor::PDF::Sanitizer FontAwesomeIconSets = %w(fab far fas) IconSets = %w(fab far fas fi pf).to_set IconSetPrefixes = IconSets.map {|it| it + '-' } InitialPageContent = %(q\n) (FontStyleToSet = { bold: [:bold].to_set, italic: [:italic].to_set, bold_italic: [:bold, :italic].to_set, }).default = ::Set.new # - :height is the height of a line # - :leading is spacing between adjacent lines # - :padding_top is half line spacing, plus any line_gap in the font # - :padding_bottom is half line spacing # - :final_gap determines whether a gap is added below the last line LineMetrics = ::Struct.new :height, :leading, :padding_top, :padding_bottom, :final_gap # Core # Retrieves the catalog reference data for the PDF. # def catalog state.store.root end # Retrieves the compatiblity version of the PDF. # def min_version state.version end # Measurements # Returns the width of the current page from edge-to-edge # def page_width page.dimensions[2] end # Returns the effective (writable) width of the page # # If inside a bounding box, returns width of box. # def effective_page_width reference_bounds.width end # Returns the height of the current page from edge-to-edge # def page_height page.dimensions[3] end # Returns the effective (writable) height of the page # # If inside a fixed-height bounding box, returns width of box. # def effective_page_height reference_bounds.height end # Set the margins for the current page. # def set_page_margin margin # FIXME is there a cleaner way to set margins? does it make sense to override create_new_page? apply_margin_options margin: margin generate_margin_box end # Returns the margins for the current page as a 4 element array (top, right, bottom, left) # def page_margin [page.margins[:top], page.margins[:right], page.margins[:bottom], page.margins[:left]] end # Returns the width of the left margin for the current page # def page_margin_left page.margins[:left] end # deprecated alias left_margin page_margin_left # Returns the width of the right margin for the current page # def page_margin_right page.margins[:right] end # deprecated alias right_margin page_margin_right # Returns the width of the top margin for the current page # def page_margin_top page.margins[:top] end # Returns the width of the bottom margin for the current page # def page_margin_bottom page.margins[:bottom] end # Returns the total left margin (to the page edge) for the current bounds. # def bounds_margin_left bounds.absolute_left end # Returns the total right margin (to the page edge) for the current bounds. # def bounds_margin_right page.dimensions[2] - bounds.absolute_right end # Returns the side the current page is facing, :recto or :verso. # def page_side pgnum = nil, invert = nil if invert (recto_page? pgnum) ? :verso : :recto else (recto_page? pgnum) ? :recto : :verso end end # Returns whether the page is a recto page. # def recto_page? pgnum = nil (pgnum || page_number).odd? end # Returns whether the page is a verso page. # def verso_page? pgnum = nil (pgnum || page_number).even? end # Returns whether the cursor is at the top of the page (i.e., margin box). # def at_page_top? @y == @margin_box.absolute_top end # Returns whether the current page is the last page in the document. # def last_page? page_number == page_count end # Destinations # Generates a destination object that resolves to the top of the page # specified by the page_num parameter or the current page if no page number # is provided. The destination preserves the user's zoom level unlike # the destinations generated by the outline builder. # def dest_top page_num = nil dest_xyz 0, page_height, nil, (page_num ? state.pages[page_num - 1] : page) end # Fonts # Registers a new custom font described in the data parameter # after converting the font name to a String. # # Example: # # register_font Roboto: { # normal: 'fonts/roboto-normal.ttf', # italic: 'fonts/roboto-italic.ttf', # bold: 'fonts/roboto-bold.ttf', # bold_italic: 'fonts/roboto-bold_italic.ttf' # } # def register_font data font_families.update data.reduce({}) {|accum, (key, val)| accum[key.to_s] = val; accum } end # Enhances the built-in font method to allow the font # size to be specified as the second option and to # lazily load font-based icons. # def font name = nil, options = {} if name options = { size: options } if ::Numeric === options if IconSets.include? name ::Prawn::Icon::FontData.load self, name options = options.reject {|k| k == :style } if options.key? :style end end super name, options end # Retrieves the current font name (i.e., family). # def font_family font.options[:family] end alias font_name font_family # Retrieves the current font info (family, style, size) as a Hash # def font_info { family: font.options[:family], style: (font.options[:style] || :normal), size: @font_size } end # Sets the font style for the scope of the block to which this method # yields. If the style is nil and no block is given, return the current # font style. # def font_style style = nil if block_given? font font.options[:family], style: style do yield end elsif style font font.options[:family], style: style else font.options[:style] || :normal end end # Applies points as a scale factor of the current font if the value provided # is less than or equal to 1 or it's a string (e.g., 1.1em), then delegates to the super # implementation to carry out the built-in functionality. # #-- # QUESTION should we round the result? def font_size points = nil return @font_size unless points if points == 1 super @font_size elsif String === points if points.end_with? 'rem' super(@root_font_size * points.to_f) elsif points.end_with? 'em' super(@font_size * points.to_f) elsif points.end_with? '%' super(@font_size * (points.to_f / 100.0)) else super points.to_f end # FIXME HACK assume em value elsif points < 1 super(@font_size * points) else super points end end def resolve_font_style styles if styles.include? :bold (styles.include? :italic) ? :bold_italic : :bold elsif styles.include? :italic :italic else :normal end end # Retreives the collection of font styles from the given font style key, # which defaults to the current font style. # def font_styles style = font_style FontStyleToSet[style].dup end # Apply the font settings (family, size, styles and character spacing) from # the fragment to the document, then yield to the block. # # The original font settings are restored before this method returns. # def fragment_font fragment f_info = font_info f_family = fragment[:font] || f_info[:family] f_size = fragment[:size] || f_info[:size] if (f_styles = fragment[:styles]) f_style = resolve_font_style f_styles else f_style = :normal end if (c_spacing = fragment[:character_spacing]) character_spacing c_spacing do font f_family, size: f_size, style: f_style do yield end end else font f_family, size: f_size, style: f_style do yield end end end def icon_font_data family ::Prawn::Icon::FontData.load self, family end def resolve_legacy_icon_name name ::Prawn::Icon::Compatibility::SHIMS[%(fa-#{name})] end def calc_line_metrics line_height = 1, font = self.font, font_size = self.font_size line_height_length = line_height * font_size leading = line_height_length - font_size half_leading = leading / 2 padding_top = half_leading + font.line_gap padding_bottom = half_leading LineMetrics.new line_height_length, leading, padding_top, padding_bottom, false end =begin # these line metrics attempted to figure out a correction based on the reported height and the font_size # however, it only works for some fonts, and breaks down for fonts like Noto Serif def calc_line_metrics line_height = 1, font = self.font, font_size = self.font_size line_height_length = font_size * line_height line_gap = line_height_length - font_size correction = font.height - font_size leading = line_gap - correction shift = (font.line_gap + correction + line_gap) / 2 final_gap = font.line_gap != 0 LineMetrics.new line_height_length, leading, shift, shift, final_gap end =end # Parse the text into an array of fragments using the text formatter. def parse_text string, options = {} return [] if string.nil? options = options.dup if (format_option = options.delete :inline_format) format_option = [] unless ::Array === format_option fragments = self.text_formatter.format string, *format_option else fragments = [{text: string}] end if (color = options.delete :color) fragments.map do |fragment| fragment[:color] ? fragment : fragment.merge(color: color) end else fragments end end # NOTE override built-in draw_indented_formatted_line to insert leading before second line def draw_indented_formatted_line string, opts result = super unless @no_text_printed || @all_text_printed # as of Prawn 1.2.1, we have to handle the line gap after the first line manually move_down opts[:leading] end result end # Performs the same work as Prawn::Text.text except that the first_line_opts are applied to the first line of text # renderered. It's necessary to use low-level APIs in this method so we only style the first line and not the # remaining lines (which is the default behavior in Prawn). def text_with_formatted_first_line string, first_line_opts, opts color = opts.delete :color fragments = parse_text string, opts # NOTE the low-level APIs we're using don't recognize the :styles option, so we must resolve if (styles = opts.delete :styles) opts[:style] = resolve_font_style styles end if (first_line_styles = first_line_opts.delete :styles) first_line_opts[:style] = resolve_font_style first_line_styles end first_line_color = (first_line_opts.delete :color) || color opts = opts.merge document: self # QUESTION should we merge more carefully here? (hand-select keys?) first_line_opts = opts.merge(first_line_opts).merge single_line: true box = ::Prawn::Text::Formatted::Box.new fragments, first_line_opts # NOTE get remaining_fragments before we add color to fragments on first line if (text_indent = opts.delete :indent_paragraphs) remaining_fragments = indent text_indent do box.render dry_run: true end else remaining_fragments = box.render dry_run: true end # NOTE color must be applied per-fragment if first_line_color fragments.each {|fragment| fragment[:color] ||= first_line_color} end if text_indent indent text_indent do fill_formatted_text_box fragments, first_line_opts end else fill_formatted_text_box fragments, first_line_opts end unless remaining_fragments.empty? # NOTE color must be applied per-fragment remaining_fragments.each {|fragment| fragment[:color] ||= color } if color # as of Prawn 1.2.1, we have to handle the line gap after the first line manually move_down opts[:leading] remaining_fragments = fill_formatted_text_box remaining_fragments, opts draw_remaining_formatted_text_on_new_pages remaining_fragments, opts end end # Apply the text transform to the specified text. # # Supported transform values are "uppercase", "lowercase", or "none" (passed # as either a String or a Symbol). When the uppercase transform is applied to # the text, it correctly uppercases visible text while leaving markup and # named character entities unchanged. The none transform returns the text # unmodified. # def transform_text text, transform case transform when :uppercase, 'uppercase' uppercase_pcdata text when :lowercase, 'lowercase' lowercase_mb text else text end end # Cursor # Short-circuits the call to the built-in move_up operation # when n is 0. # def move_up n super unless n == 0 end # Override built-in move_text_position method to prevent Prawn from advancing # to next page if image doesn't fit before rendering image. #-- # NOTE could use :at option when calling image/embed_image instead def move_text_position h end # Short-circuits the call to the built-in move_down operation # when n is 0. # def move_down n super unless n == 0 end # Bounds # Overrides the built-in pad operation to allow for asymmetric paddings. # # Example: # # pad 20, 10 do # text 'A paragraph with twice as much top padding as bottom padding.' # end # def pad top, bottom = nil move_down top yield move_down(bottom || top) end # Combines the built-in pad and indent operations into a single method. # # Padding may be specified as an array of four values, or as a single value. # The single value is used as the padding around all four sides of the box. # # If padding is nil, this method simply yields to the block and returns. # # Example: # # pad_box 20 do # text 'A paragraph inside a blox with even padding on all sides.' # end # # pad_box [10, 10, 10, 20] do # text 'An indented paragraph inside a box with equal padding on all sides.' # end # def pad_box padding if padding # TODO implement shorthand combinations like in CSS p_top, p_right, p_bottom, p_left = ::Array === padding ? padding : (::Array.new 4, padding) begin # logic is intentionally inlined move_down p_top bounds.add_left_padding p_left bounds.add_right_padding p_right yield # NOTE support negative bottom padding for use with quote block if p_bottom < 0 # QUESTION should we return to previous page if top of page is reached? p_bottom < cursor - reference_bounds.top ? (move_cursor_to reference_bounds.top) : (move_down p_bottom) else p_bottom < cursor ? (move_down p_bottom) : reference_bounds.move_past_bottom end ensure bounds.subtract_left_padding p_left bounds.subtract_right_padding p_right end else yield end # alternate, delegated logic #pad padding[0], padding[2] do # indent padding[1], padding[3] do # yield # end #end end def inflate_indent value (::Array === value ? (value.slice 0, 2) : (::Array.new 2, value)).map(&:to_f) end # TODO memoize the result def inflate_padding padding padding = [*(padding || 0)].slice 0, 4 case padding.size when 1 [padding[0], padding[0], padding[0], padding[0]] when 2 [padding[0], padding[1], padding[0], padding[1]] when 3 [padding[0], padding[1], padding[2], padding[1]] else padding end end # Stretch the current bounds to the left and right edges of the current page # while yielding the specified block if the verdict argument is true. # Otherwise, simply yield the specified block. # def span_page_width_if verdict if verdict indent(-bounds_margin_left, -bounds_margin_right) do yield end else yield end end # A flowing version of the bounding_box. If the content runs to another page, the cursor starts # at the top of the page instead of the original cursor position. Similar to span, except # you can specify an absolute left position and pass additional options through to bounding_box. # def flow_bounding_box left = 0, opts = {} original_y = self.y # QUESTION should preserving original_x be an option? original_x = bounds.absolute_left - margin_box.absolute_left canvas do bounding_box [margin_box.absolute_left + original_x + left, margin_box.absolute_top], opts do self.y = original_y yield end end end # Graphics # Fills the current bounding box with the specified fill color. Before # returning from this method, the original fill color on the document is # restored. def fill_bounds f_color = fill_color if f_color && f_color != 'transparent' prev_fill_color = fill_color fill_color f_color fill_rectangle bounds.top_left, bounds.width, bounds.height fill_color prev_fill_color end end # Fills the absolute bounding box with the specified fill color. Before # returning from this method, the original fill color on the document is # restored. def fill_absolute_bounds f_color = fill_color canvas { fill_bounds f_color } end # Fills the current bounds using the specified fill color and strokes the # bounds using the specified stroke color. Sets the line with if specified # in the options. Before returning from this method, the original fill # color, stroke color and line width on the document are restored. # def fill_and_stroke_bounds f_color = fill_color, s_color = stroke_color, options = {} no_fill = !f_color || f_color == 'transparent' no_stroke = !s_color || s_color == 'transparent' || options[:line_width] == 0 return if no_fill && no_stroke save_graphics_state do radius = options[:radius] || 0 # fill unless no_fill fill_color f_color fill_rounded_rectangle bounds.top_left, bounds.width, bounds.height, radius end # stroke unless no_stroke stroke_color s_color line_width(options[:line_width] || 0.5) # FIXME think about best way to indicate dashed borders #if options.has_key? :dash_width # dash options[:dash_width], space: options[:dash_space] || 1 #end stroke_rounded_rectangle bounds.top_left, bounds.width, bounds.height, radius #undash if options.has_key? :dash_width end end end # Fills and, optionally, strokes the current bounds using the fill and # stroke color specified, then yields to the block. The only_if option can # be used to conditionally disable this behavior. # def shade_box color, line_color = nil, options = {} if (!options.has_key? :only_if) || options[:only_if] # FIXME could use save_graphics_state here previous_fill_color = current_fill_color fill_color color fill_rectangle [bounds.left, bounds.top], bounds.right, bounds.top - bounds.bottom fill_color previous_fill_color if line_color line_width 0.5 previous_stroke_color = current_stroke_color stroke_color line_color stroke_bounds stroke_color previous_stroke_color end end yield end # A compliment to the stroke_horizontal_rule method, strokes a # vertical line using the current bounds. The width of the line # can be specified using the line_width option. The horizontal (x) # position can be specified using the at option. # def stroke_vertical_rule rule_color = stroke_color, options = {} rule_x = options[:at] || 0 rule_y_from = bounds.top rule_y_to = bounds.bottom rule_style = options[:line_style] rule_width = options[:line_width] || 0.5 save_graphics_state do line_width rule_width stroke_color rule_color case rule_style when :dashed dash rule_width * 4 when :dotted dash rule_width when :double stroke_vertical_line rule_y_from, rule_y_to, at: (rule_x - rule_width) rule_x += rule_width end if rule_style stroke_vertical_line rule_y_from, rule_y_to, at: rule_x end end # Strokes a horizontal line using the current bounds. The width of the line # can be specified using the line_width option. # def stroke_horizontal_rule rule_color = stroke_color, options = {} rule_style = options[:line_style] rule_width = options[:line_width] || 0.5 rule_x_start = bounds.left rule_x_end = bounds.right rule_inked = false save_graphics_state do line_width rule_width stroke_color rule_color case rule_style when :dashed dash rule_width * 4 when :dotted dash rule_width when :double move_up rule_width stroke_horizontal_line rule_x_start, rule_x_end move_down rule_width * 2 stroke_horizontal_line rule_x_start, rule_x_end move_up rule_width rule_inked = true end if rule_style stroke_horizontal_line rule_x_start, rule_x_end unless rule_inked end end # Pages # Deletes the current page and move the cursor # to the previous page. def delete_page pg = page_number pdf_store = state.store pdf_objs = pdf_store.instance_variable_get :@objects pdf_ids = pdf_store.instance_variable_get :@identifiers page_id = pdf_store.object_id_for_page pg content_id = page.content.identifier [page_id, content_id].each do |key| pdf_objs.delete key pdf_ids.delete key end pdf_store.pages.data[:Kids].pop pdf_store.pages.data[:Count] -= 1 state.pages.pop if pg > 1 go_to_page pg - 1 else @page_number = 0 state.page = nil end end # Import the specified page into the current document. # # By default, advance to the next page afterwards, creating it if necessary. # This behavior can be disabled by passing the option `advance: false`. # However, due to how page creation works in Prawn, understand that advancing # to the next page is necessary to prevent the size & layout of the imported # page from affecting a newly created page. def import_page file, opts = {} prev_page_layout = page.layout prev_page_size = page.size state.compress = false if state.compress # can't use compression if using template prev_text_rendering_mode = (defined? @text_rendering_mode) ? @text_rendering_mode : nil delete_page if opts[:replace] # NOTE use functionality provided by prawn-templates start_new_page_discretely template: file, template_page: opts[:page] # prawn-templates sets text_rendering_mode to :unknown, which breaks running content; revert @text_rendering_mode = prev_text_rendering_mode if opts.fetch :advance, true # NOTE set page size & layout explicitly in case imported page differs # I'm not sure it's right to start a new page here, but unfortunately there's no other # way atm to prevent the size & layout of the imported page from affecting subsequent pages advance_page size: prev_page_size, layout: prev_page_layout end nil end # Create a new page for the specified image. If the canvas option is true, # the image is positioned relative to the boundaries of the page. def image_page file, options = {} start_new_page_discretely image_page_number = page_number if options.delete :canvas canvas { image file, ({ position: :center, vposition: :center }.merge options) } else image file, (options.merge position: :center, vposition: :center, fit: [bounds.width, bounds.height]) end # NOTE advance to newly created page just in case the image function threw off the cursor go_to_page image_page_number nil end # Perform an operation (such as creating a new page) without triggering the on_page_create callback # def perform_discretely if (saved_callback = state.on_page_create_callback) # equivalent to calling `on_page_create` state.on_page_create_callback = nil yield # equivalent to calling `on_page_create &saved_callback` state.on_page_create_callback = saved_callback else yield end end # This method is a smarter version of start_new_page. It calls start_new_page # if the current page is the last page of the document. Otherwise, it simply # advances to the next existing page. def advance_page opts = {} last_page? ? (start_new_page opts) : (go_to_page page_number + 1) end # Start a new page without triggering the on_page_create callback # def start_new_page_discretely options = {} perform_discretely { start_new_page options } end # Grouping # Conditional group operation # def group_if verdict if verdict state.optimize_objects = false # optimize objects breaks group group { yield } else yield end end def get_scratch_document # marshal if not using transaction feature #Marshal.load Marshal.dump @prototype # use cached instance, tests show it's faster #@prototype ||= ::Prawn::Document.new @scratch ||= if defined? @prototype scratch = Marshal.load Marshal.dump @prototype scratch.instance_variable_set(:@prototype, @prototype) # TODO set scratch number on scratch document scratch else logger.warn 'no scratch prototype available; instantiating fresh scratch document' ::Prawn::Document.new end end def scratch? (@_label ||= (state.store.info.data[:Scratch] ? :scratch : :primary)) == :scratch rescue false # NOTE this method may get called before the state is initialized end alias is_scratch? scratch? # TODO document me def dry_run &block scratch = get_scratch_document # QUESTION should we use scratch.advance_page instead? scratch.start_new_page start_page_number = scratch.page_number start_y = scratch.y if (left_padding = bounds.total_left_padding) > 0 scratch.bounds.add_left_padding left_padding end if (right_padding = bounds.total_right_padding) > 0 scratch.bounds.add_right_padding right_padding end scratch.font font_family, style: font_style, size: font_size do scratch.instance_exec(&block) end # NOTE don't count excess if cursor exceeds writable area (due to padding) full_page_height = scratch.effective_page_height partial_page_height = [full_page_height, start_y - scratch.y].min scratch.bounds.subtract_left_padding left_padding if left_padding > 0 scratch.bounds.subtract_right_padding right_padding if right_padding > 0 whole_pages = scratch.page_number - start_page_number [(whole_pages * full_page_height + partial_page_height), whole_pages, partial_page_height] end # Attempt to keep the objects generated in the block on the same page # # TODO short-circuit nested usage def keep_together &block available_space = cursor total_height, _, _ = dry_run(&block) # NOTE technically, if we're at the page top, we don't even need to do the # dry run, except several uses of this method rely on the calculated height if total_height > available_space && !at_page_top? && total_height <= effective_page_height advance_page started_new_page = true else started_new_page = false end # HACK yield doesn't work here on JRuby (at least not when called from AsciidoctorJ) #yield remainder, started_new_page instance_exec(total_height, started_new_page, &block) end # Attempt to keep the objects generated in the block on the same page # if the verdict parameter is true. # def keep_together_if verdict, &block if verdict keep_together(&block) else yield end end =begin def run_with_trial &block available_space = cursor total_height, whole_pages, remainder = dry_run(&block) if whole_pages > 0 || remainder > available_space started_new_page = true else started_new_page = false end # HACK yield doesn't work here on JRuby (at least not when called from AsciidoctorJ) #yield remainder, started_new_page instance_exec(remainder, started_new_page, &block) end =end end end end