Last active
July 27, 2018 10:31
-
-
Save algmyr/d59e9051cae9756df75bc2e89bbc4777 to your computer and use it in GitHub Desktop.
Kramdown syntax highlighting hack
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*- | |
# | |
#-- | |
# Copyright (C) 2009-2016 Thomas Leitner <[email protected]> | |
# | |
# 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' | |
require 'coderay' | |
module Kramdown | |
module Converter | |
# hackity hack | |
class PrawnEncoder < CodeRay::Encoders::Encoder | |
register_for :to_prawn | |
COLORS = { :default => "24292E", | |
:operator => "24292E", | |
:content => "24292E", | |
:space => "24292E", | |
:error => "FF0000", | |
:comment => "6A737D", | |
:constant => "005CC5", | |
:integer => "005CC5", | |
:float => "005CC5", | |
:inline_delimiter => "234543", # #{} within a string | |
:keyword => "D73A49", | |
:method => "6F42C1", | |
:ident => "24292E", # <-- also includes methods... | |
:string => "032F62", | |
:predefined_type => "EE6549", | |
:directive => "EE6549", | |
:predefined_constant => "EE6549", | |
:predefined => "EE6549", | |
:preprocessor => "EE6549", | |
:delimiter => "005CC5" | |
} | |
def setup(options) | |
super | |
@out = [] | |
@open = [] | |
end | |
def text_token(text, kind) | |
color = COLORS[kind] || COLORS[@open.last] || COLORS[:default] | |
@out << {:text => text, :color => color} | |
end | |
def begin_group(kind) | |
@open << kind | |
end | |
def end_group(kind) | |
@open.pop | |
end | |
end | |
# end hackity hack | |
# 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? && [email protected]_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 | |
# Hackity hack | |
def render_codeblock(el, opts) | |
lang = el.options[:lang] ? el.options[:lang] : "plain" | |
code = el.value.gsub(' ', Prawn::Text::NBSP) | |
pre_text = CodeRay.scan(code, lang).to_prawn | |
with_block_padding(el, opts) do | |
@pdf.formatted_text(pre_text, opts) | |
end | |
end | |
# end hackity hack | |
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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment