blob: 4da9c8505e6baa2221bf798d9621165c3d567724 [file] [log] [blame]
# -*- coding: utf-8 -*-
#
#--
# Copyright (C) 2009-2016 Thomas Leitner <t_leitner@gmx.at>
#
# This file is part of kramdown which is licensed under the MIT.
#++
#
require 'prawn'
require 'prawn/table'
require 'kramdown/converter'
require 'kramdown/utils'
require 'open-uri'
module Kramdown
module Converter
# Converts an element tree to a PDF using the prawn PDF library.
#
# This basic version provides a nice starting point for customizations but can also be used
# directly.
#
# There can be the following two methods for each element type: render_TYPE(el, opts) and
# TYPE_options(el, opts) where +el+ is a kramdown element and +opts+ an hash with rendering
# options.
#
# The render_TYPE(el, opts) is used for rendering the specific element. If the element is a span
# element, it should return a hash or an array of hashes that can be used by the #formatted_text
# method of Prawn::Document. This method can then be used in block elements to actually render
# the span elements.
#
# The rendering options are passed from the parent to its child elements. This allows one to
# define general options at the top of the tree (the root element) that can later be changed or
# amended.
#
#
# Currently supports the conversion of all elements except those of the following types:
#
# :html_element, :img, :footnote
#
#
class Pdf < Base
include Prawn::Measurements
def initialize(root, options)
super
@stack = []
@dests = {}
end
# PDF templates are applied before conversion. They should contain code to augment the
# converter object (i.e. to override the methods).
def apply_template_before?
true
end
# Returns +false+.
def apply_template_after?
false
end
DISPATCHER_RENDER = Hash.new {|h,k| h[k] = "render_#{k}"} #:nodoc:
DISPATCHER_OPTIONS = Hash.new {|h,k| h[k] = "#{k}_options"} #:nodoc:
# Invoke the special rendering method for the given element +el+.
#
# A PDF destination is also added at the current location if th element has an ID or if the
# element is of type :header and the :auto_ids option is set.
def convert(el, opts = {})
id = el.attr['id']
id = generate_id(el.options[:raw_text]) if !id && @options[:auto_ids] && el.type == :header
if !id.to_s.empty? && !@dests.has_key?(id)
@pdf.add_dest(id, @pdf.dest_xyz(0, @pdf.y))
@dests[id] = @pdf.dest_xyz(0, @pdf.y)
end
send(DISPATCHER_RENDER[el.type], el, opts)
end
protected
# Render the children of this element with the given options and return the results as array.
#
# Each time a child is rendered, the +TYPE_options+ method is invoked (if it exists) to get
# the specific options for the element with which the given options are updated.
def inner(el, opts)
@stack.push([el, opts])
result = el.children.map do |inner_el|
options = opts.dup
options.update(send(DISPATCHER_OPTIONS[inner_el.type], inner_el, options))
convert(inner_el, options)
end.flatten.compact
@stack.pop
result
end
# ----------------------------
# :section: Element rendering methods
# ----------------------------
def root_options(root, opts)
{:font => 'Times-Roman', :size => 12, :leading => 2}
end
def render_root(root, opts)
@pdf = setup_document(root)
inner(root, root_options(root, opts))
create_outline(root)
finish_document(root)
@pdf.render
end
def header_options(el, opts)
size = opts[:size] * 1.15**(6 - el.options[:level])
{
:font => "Helvetica", :styles => (opts[:styles] || []) + [:bold],
:size => size, :bottom_padding => opts[:size], :top_padding => opts[:size]
}
end
def render_header(el, opts)
render_padded_and_formatted_text(el, opts)
end
def p_options(el, opts)
bpad = (el.options[:transparent] ? opts[:leading] : opts[:size])
{:align => :justify, :bottom_padding => bpad}
end
def render_p(el, opts)
if el.children.size == 1 && el.children.first.type == :img
render_standalone_image(el, opts)
else
render_padded_and_formatted_text(el, opts)
end
end
def render_standalone_image(el, opts)
img = el.children.first
line = img.options[:location]
if img.attr['src'].empty?
warning("Rendering an image without a source is not possible#{line ? " (line #{line})" : ''}")
return nil
elsif img.attr['src'] !~ /\.jpe?g$|\.png$/
warning("Cannot render images other than JPEG or PNG, got #{img.attr['src']}#{line ? " on line #{line}" : ''}")
return nil
end
img_dirs = (@options[:image_directories] || ['.']).dup
begin
img_path = File.join(img_dirs.shift, img.attr['src'])
image_obj, image_info = @pdf.build_image_object(open(img_path))
rescue
img_dirs.empty? ? raise : retry
end
options = {:position => :center}
if img.attr['height'] && img.attr['height'] =~ /px$/
options[:height] = img.attr['height'].to_i / (@options[:image_dpi] || 150.0) * 72
elsif img.attr['width'] && img.attr['width'] =~ /px$/
options[:width] = img.attr['width'].to_i / (@options[:image_dpi] || 150.0) * 72
else
options[:scale] =[(@pdf.bounds.width - mm2pt(20)) / image_info.width.to_f, 1].min
end
if img.attr['class'] =~ /\bright\b/
options[:position] = :right
@pdf.float { @pdf.embed_image(image_obj, image_info, options) }
else
with_block_padding(el, opts) do
@pdf.embed_image(image_obj, image_info, options)
end
end
end
def blockquote_options(el, opts)
{:styles => [:italic]}
end
def render_blockquote(el, opts)
@pdf.indent(mm2pt(10), mm2pt(10)) { inner(el, opts) }
end
def ul_options(el, opts)
{:bottom_padding => opts[:size]}
end
def render_ul(el, opts)
with_block_padding(el, opts) do
el.children.each do |li|
@pdf.float { @pdf.formatted_text([text_hash("•", opts)]) }
@pdf.indent(mm2pt(6)) { convert(li, opts) }
end
end
end
def ol_options(el, opts)
{:bottom_padding => opts[:size]}
end
def render_ol(el, opts)
with_block_padding(el, opts) do
el.children.each_with_index do |li, index|
@pdf.float { @pdf.formatted_text([text_hash("#{index+1}.", opts)]) }
@pdf.indent(mm2pt(6)) { convert(li, opts) }
end
end
end
def li_options(el, opts)
{}
end
def render_li(el, opts)
inner(el, opts)
end
def dl_options(el, opts)
{}
end
def render_dl(el, opts)
inner(el, opts)
end
def dt_options(el, opts)
{:styles => (opts[:styles] || []) + [:bold], :bottom_padding => 0}
end
def render_dt(el, opts)
render_padded_and_formatted_text(el, opts)
end
def dd_options(el, opts)
{}
end
def render_dd(el, opts)
@pdf.indent(mm2pt(10)) { inner(el, opts) }
end
def math_options(el, opts)
{}
end
def render_math(el, opts)
if el.options[:category] == :block
@pdf.formatted_text([{:text => el.value}], block_hash(opts))
else
{:text => el.value}
end
end
def hr_options(el, opts)
{:top_padding => opts[:size], :bottom_padding => opts[:size]}
end
def render_hr(el, opts)
with_block_padding(el, opts) do
@pdf.stroke_horizontal_line(@pdf.bounds.left + mm2pt(5), @pdf.bounds.right - mm2pt(5))
end
end
def codeblock_options(el, opts)
{
:font => 'Courier', :color => '880000',
:bottom_padding => opts[:size]
}
end
def render_codeblock(el, opts)
with_block_padding(el, opts) do
@pdf.formatted_text([text_hash(el.value, opts, false)], block_hash(opts))
end
end
def table_options(el, opts)
{:bottom_padding => opts[:size]}
end
def render_table(el, opts)
data = []
el.children.each do |container|
container.children.each do |row|
data << []
row.children.each do |cell|
if cell.children.any? {|child| child.options[:category] == :block}
line = el.options[:location]
warning("Can't render tables with cells containing block elements#{line ? " (line #{line})" : ''}")
return
end
cell_data = inner(cell, opts)
data.last << cell_data.map {|c| c[:text]}.join('')
end
end
end
with_block_padding(el, opts) do
@pdf.table(data, :width => @pdf.bounds.right) do
el.options[:alignment].each_with_index do |alignment, index|
columns(index).align = alignment unless alignment == :default
end
end
end
end
def text_options(el, opts)
{}
end
def render_text(el, opts)
text_hash(el.value.to_s, opts)
end
def em_options(el, opts)
if opts[:styles] && opts[:styles].include?(:italic)
{:styles => opts[:styles].reject {|i| i == :italic}}
else
{:styles => (opts[:styles] || []) << :italic}
end
end
def strong_options(el, opts)
{:styles => (opts[:styles] || []) + [:bold]}
end
def a_options(el, opts)
hash = {:color => '000088'}
if el.attr['href'].start_with?('#')
hash[:anchor] = el.attr['href'].sub(/\A#/, '')
else
hash[:link] = el.attr['href']
end
hash
end
def render_em(el, opts)
inner(el, opts)
end
alias_method :render_strong, :render_em
alias_method :render_a, :render_em
def codespan_options(el, opts)
{:font => 'Courier', :color => '880000'}
end
def render_codespan(el, opts)
text_hash(el.value, opts)
end
def br_options(el, opts)
{}
end
def render_br(el, opts)
text_hash("\n", opts, false)
end
def smart_quote_options(el, opts)
{}
end
def render_smart_quote(el, opts)
text_hash(smart_quote_entity(el).char, opts)
end
def typographic_sym_options(el, opts)
{}
end
def render_typographic_sym(el, opts)
str = if el.value == :laquo_space
::Kramdown::Utils::Entities.entity('laquo').char +
::Kramdown::Utils::Entities.entity('nbsp').char
elsif el.value == :raquo_space
::Kramdown::Utils::Entities.entity('raquo').char +
::Kramdown::Utils::Entities.entity('nbsp').char
else
::Kramdown::Utils::Entities.entity(el.value.to_s).char
end
text_hash(str, opts)
end
def entity_options(el, opts)
{}
end
def render_entity(el, opts)
text_hash(el.value.char, opts)
end
def abbreviation_options(el, opts)
{}
end
def render_abbreviation(el, opts)
text_hash(el.value, opts)
end
def img_options(el, opts)
{}
end
def render_img(el, *args) #:nodoc:
line = el.options[:location]
warning("Rendering span images is not supported for PDF converter#{line ? " (line #{line})" : ''}")
nil
end
def xml_comment_options(el, opts) #:nodoc:
{}
end
alias_method :xml_pi_options, :xml_comment_options
alias_method :comment_options, :xml_comment_options
alias_method :blank_options, :xml_comment_options
alias_method :footnote_options, :xml_comment_options
alias_method :raw_options, :xml_comment_options
alias_method :html_element_options, :xml_comment_options
def render_xml_comment(el, opts) #:nodoc:
# noop
end
alias_method :render_xml_pi, :render_xml_comment
alias_method :render_comment, :render_xml_comment
alias_method :render_blank, :render_xml_comment
def render_footnote(el, *args) #:nodoc:
line = el.options[:location]
warning("Rendering #{el.type} not supported for PDF converter#{line ? " (line #{line})" : ''}")
nil
end
alias_method :render_raw, :render_footnote
alias_method :render_html_element, :render_footnote
# ----------------------------
# :section: Organizational methods
#
# These methods are used, for example, to up the needed Prawn::Document instance or to create
# a PDF outline.
# ----------------------------
# This module gets mixed into the Prawn::Document instance.
module PrawnDocumentExtension
# Extension for the formatted box class to recognize images and move text around them.
module CustomBox
def available_width
return super unless @document.respond_to?(:converter) && @document.converter
@document.image_floats.each do |pn, x, y, w, h|
next if @document.page_number != pn
if @at[1] + @baseline_y <= y - @document.bounds.absolute_bottom &&
(@at[1] + @baseline_y + @arranger.max_line_height + @leading >= y - h - @document.bounds.absolute_bottom)
return @width - w
end
end
return super
end
end
Prawn::Text::Formatted::Box.extensions << CustomBox
# Access the converter instance from within Prawn
attr_accessor :converter
def image_floats
@image_floats ||= []
end
# Override image embedding method for adding image positions to #image_floats.
def embed_image(pdf_obj, info, options)
# find where the image will be placed and how big it will be
w,h = info.calc_image_dimensions(options)
if options[:at]
x,y = map_to_absolute(options[:at])
else
x,y = image_position(w,h,options)
move_text_position h
end
#--> This part is new
if options[:position] == :right
image_floats << [page_number, x - 15, y, w + 15, h + 15]
end
# add a reference to the image object to the current page
# resource list and give it a label
label = "I#{next_image_id}"
state.page.xobjects.merge!(label => pdf_obj)
# add the image to the current page
instruct = "\nq\n%.3f 0 0 %.3f %.3f %.3f cm\n/%s Do\nQ"
add_content instruct % [ w, h, x, y - h, label ]
end
end
# Return a hash with options that are suitable for Prawn::Document.new.
#
# Used in #setup_document.
def document_options(root)
{
:page_size => 'A4', :page_layout => :portrait, :margin => mm2pt(20),
:info => {
:Creator => 'kramdown PDF converter',
:CreationDate => Time.now
},
:compress => true, :optimize_objects => true
}
end
# Create a Prawn::Document object and return it.
#
# Can be used to define repeatable content or register fonts.
#
# Used in #render_root.
def setup_document(root)
doc = Prawn::Document.new(document_options(root))
doc.extend(PrawnDocumentExtension)
doc.converter = self
doc
end
#
#
# Used in #render_root.
def finish_document(root)
# no op
end
# Create the PDF outline from the header elements in the TOC.
def create_outline(root)
toc = ::Kramdown::Converter::Toc.convert(root).first
text_of_header = lambda do |el|
if el.type == :text
el.value
else
el.children.map {|c| text_of_header.call(c)}.join('')
end
end
add_section = lambda do |item, parent|
text = text_of_header.call(item.value)
destination = @dests[item.attr[:id]]
if !parent
@pdf.outline.page(:title => text, :destination => destination)
else
@pdf.outline.add_subsection_to(parent) do
@pdf.outline.page(:title => text, :destination => destination)
end
end
item.children.each {|c| add_section.call(c, text)}
end
toc.children.each do |item|
add_section.call(item, nil)
end
end
# ----------------------------
# :section: Helper methods
# ----------------------------
# Move the prawn document cursor down before and/or after yielding the given block.
#
# The :top_padding and :bottom_padding options are used for determinig the padding amount.
def with_block_padding(el, opts)
@pdf.move_down(opts[:top_padding]) if opts.has_key?(:top_padding)
yield
@pdf.move_down(opts[:bottom_padding]) if opts.has_key?(:bottom_padding)
end
# Render the children of the given element as formatted text and respect the top/bottom
# padding (see #with_block_padding).
def render_padded_and_formatted_text(el, opts)
with_block_padding(el, opts) { @pdf.formatted_text(inner(el, opts), block_hash(opts)) }
end
# Helper function that returns a hash with valid "formatted text" options.
#
# The +text+ parameter is used as value for the :text key and if +squeeze_whitespace+ is
# +true+, all whitespace is converted into spaces.
def text_hash(text, opts, squeeze_whitespace = true)
text = text.gsub(/\s+/, ' ') if squeeze_whitespace
hash = {:text => text}
[:styles, :size, :character_spacing, :font, :color, :link,
:anchor, :draw_text_callback, :callback].each do |key|
hash[key] = opts[key] if opts.has_key?(key)
end
hash
end
# Helper function that returns a hash with valid options for the prawn #text_box extracted
# from the given options.
def block_hash(opts)
hash = {}
[:align, :valign, :mode, :final_gap, :leading, :fallback_fonts,
:direction, :indent_paragraphs].each do |key|
hash[key] = opts[key] if opts.has_key?(key)
end
hash
end
end
end
end