625 lines
18 KiB
Ruby
625 lines
18 KiB
Ruby
# -*- 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
|