lib/prawn_html/document_renderer.rb



# frozen_string_literal: true

module PrawnHtml
  class DocumentRenderer
    NEW_LINE = { text: "\n" }.freeze
    SPACE = { text: ' ' }.freeze

    # Init the DocumentRenderer
    #
    # @param pdf [PdfWrapper] target PDF wrapper
    def initialize(pdf)
      @before_content = []
      @buffer = []
      @context = Context.new
      @last_margin = 0
      @last_text = ''
      @last_tag_open = false
      @pdf = pdf
    end

    # On tag close callback
    #
    # @param element [Tag] closing element wrapper
    def on_tag_close(element)
      render_if_needed(element)
      apply_tag_close_styles(element)
      context.remove_last
      @last_tag_open = false
      @last_text = ''
    end

    # On tag open callback
    #
    # @param tag_name [String] the tag name of the opening element
    # @param attributes [Hash] an hash of the element attributes
    # @param element_styles [String] document styles to apply to the element
    #
    # @return [Tag] the opening element wrapper
    def on_tag_open(tag_name, attributes:, element_styles: '')
      tag_class = Tag.class_for(tag_name)
      return unless tag_class

      options = { width: pdf.page_width, height: pdf.page_height }
      tag_class.new(tag_name, attributes: attributes, options: options).tap do |element|
        setup_element(element, element_styles: element_styles)
        @before_content.push(element.before_content) if element.respond_to?(:before_content)
        @last_tag_open = true
      end
    end

    # On text node callback
    #
    # @param content [String] the text node content
    #
    # @return [NilClass] nil value (=> no element)
    def on_text_node(content)
      if context.last.respond_to?(:on_text_node)
        # Avoid geting text from table rendered before it.
        context.last.on_text_node(content)
        return
      end

      return if context.previous_tag&.block? && content.match?(/\A\s*\Z/)

      text = prepare_text(content)
      buffer << context.merged_styles.merge(text: text) unless text.empty?
      context.last_text_node = true
      nil
    end

    # Render the buffer content to the PDF document
    def render
      return if buffer.empty?

      output_content(buffer.dup, context.block_styles)
      buffer.clear
      @last_margin = 0
    end

    alias_method :flush, :render

    private

    attr_reader :buffer, :context, :last_margin, :pdf

    def setup_element(element, element_styles:)
      render_if_needed(element)
      context.add(element)
      element.process_styles(element_styles: element_styles)
      apply_tag_open_styles(element)
      element.custom_render(pdf, context) if element.respond_to?(:custom_render)
    end

    def render_if_needed(element)
      render_needed = element&.block? && buffer.any? && buffer.last != NEW_LINE
      return false unless render_needed

      render
      true
    end

    def apply_tag_close_styles(element)
      tag_styles = element.tag_close_styles
      @last_margin = tag_styles[:margin_bottom].to_f
      puts "apply_tag_close_styles(#{element.tag}), margin_bottom=#{tag_styles[:margin_bottom]}, last_margin=#{@last_margin}"
      pdf.advance_cursor(last_margin + tag_styles[:padding_bottom].to_f)
      pdf.start_new_page if tag_styles[:break_after]
    end

    def apply_tag_open_styles(element)
      tag_styles = element.tag_open_styles
      move_down = (tag_styles[:margin_top].to_f - last_margin) + tag_styles[:padding_top].to_f
      puts "apply_tag_open_styles(#{element.tag}), margin_top=#{tag_styles[:margin_top]}, padding_top: #{tag_styles[:padding_top]} last_margin=#{@last_margin}"
      pdf.advance_cursor(move_down) if move_down > 0
      pdf.start_new_page if tag_styles[:break_before]
    end

    def prepare_text(content)
      text = @before_content.any? ? ::Oga::HTML::Entities.decode(@before_content.join) : ''
      @before_content.clear

      return (@last_text = text + content) if context.white_space_pre?

      content = content.lstrip if @last_text[-1] == ' ' || @last_tag_open
      text += content.tr("\n", ' ').squeeze(' ')
      @last_text = text
    end

    def output_content(buffer, block_styles)
      apply_callbacks(buffer)
      left_indent = block_styles[:margin_left].to_f + block_styles[:padding_left].to_f
      options = block_styles.slice(:align, :indent_paragraphs, :leading, :mode, :padding_left)
      options[:leading] = adjust_leading(buffer, options[:leading])
      pdf.puts(buffer, options, bounding_box: bounds(buffer, options, block_styles), left_indent: left_indent)
    end

    def apply_callbacks(buffer)
      buffer.select { |item| item[:callback] }.each do |item|
        callback, arg = item[:callback]
        callback_class = Tag::CALLBACKS[callback]
        item[:callback] = callback_class.new(pdf, arg)
      end
    end

    def adjust_leading(buffer, leading)
      return leading if leading

      leadings = buffer.map do |item|
        (item[:size] || Context::DEFAULT_STYLES[:size]) * (ADJUST_LEADING[item[:font]] || ADJUST_LEADING[nil])
      end
      leadings.max.round(4)
    end

    def bounds(buffer, options, block_styles)
      return unless block_styles[:position] == :absolute

      x = if block_styles.include?(:right)
            x1 = pdf.calc_buffer_width(buffer) + block_styles[:right]
            x1 < pdf.page_width ? (pdf.page_width - x1) : 0
          else
            block_styles[:left] || 0
          end
      y = if block_styles.include?(:bottom)
            pdf.calc_buffer_height(buffer, options) + block_styles[:bottom]
          else
            pdf.page_height - (block_styles[:top] || 0)
          end

      [[x, y], { width: pdf.page_width - x }]
    end
  end
end