lib/gollum/tex.rb in gollum-2.0.0 vs lib/gollum/tex.rb in gollum-2.1.0
- old
+ new
@@ -1,89 +1,366 @@
require 'fileutils'
require 'shellwords'
require 'tmpdir'
require 'posix/spawn'
+require 'base64'
module Gollum
module Tex
class Error < StandardError; end
extend POSIX::Spawn
Template = <<-EOS
-\\documentclass[12pt]{article}
-\\usepackage{color}
-\\usepackage[dvips]{graphicx}
+\\documentclass[11pt]{article}
\\pagestyle{empty}
-\\pagecolor{white}
-\\begin{document}
-{\\color{black}
-\\begin{eqnarray*}
-%s
-\\end{eqnarray*}}
+\\setlength{\\topskip}{0pt}
+\\setlength{\\parindent}{0pt}
+\\setlength{\\abovedisplayskip}{0pt}
+\\setlength{\\belowdisplayskip}{0pt}
+
+\\usepackage{geometry}
+
+\\usepackage{amsfonts}
+\\usepackage{amsmath}
+
+\\newsavebox{\\snippetbox}
+\\newlength{\\snippetwidth}
+\\newlength{\\snippetheight}
+\\newlength{\\snippetdepth}
+\\newlength{\\pagewidth}
+\\newlength{\\pageheight}
+\\newlength{\\pagemargin}
+
+\\begin{lrbox}{\\snippetbox}%
+\$%s\$
+\\end{lrbox}
+
+\\settowidth{\\snippetwidth}{\\usebox{\\snippetbox}}
+\\settoheight{\\snippetheight}{\\usebox{\\snippetbox}}
+\\settodepth{\\snippetdepth}{\\usebox{\\snippetbox}}
+
+\\setlength\\pagemargin{4pt}
+
+\\setlength\\pagewidth\\snippetwidth
+\\addtolength\\pagewidth\\pagemargin
+\\addtolength\\pagewidth\\pagemargin
+
+\\setlength\\pageheight\\snippetheight
+\\addtolength{\\pageheight}{\\snippetdepth}
+\\addtolength\\pageheight\\pagemargin
+\\addtolength\\pageheight\\pagemargin
+
+\\newwrite\\foo
+\\immediate\\openout\\foo=\\jobname.dimensions
+ \\immediate\\write\\foo{snippetdepth = \\the\\snippetdepth}
+ \\immediate\\write\\foo{snippetheight = \\the\\snippetheight}
+ \\immediate\\write\\foo{snippetwidth = \\the\\snippetwidth}
+ \\immediate\\write\\foo{pagewidth = \\the\\pagewidth}
+ \\immediate\\write\\foo{pageheight = \\the\\pageheight}
+ \\immediate\\write\\foo{pagemargin = \\the\\pagemargin}
+\\closeout\\foo
+
+\\geometry{paperwidth=\\pagewidth,paperheight=\\pageheight,margin=\\pagemargin}
+
+\\begin{document}%
+\\usebox{\\snippetbox}%
\\end{document}
EOS
class << self
- attr_accessor :latex_path, :dvips_path, :convert_path
+ attr_accessor :latex_path
end
- self.latex_path = 'latex'
- self.dvips_path = 'dvips'
- self.convert_path = 'convert'
+ self.latex_path = 'pdflatex'
def self.check_dependencies!
return if @dependencies_available
- if `which latex` == ""
- raise Error, "`latex` command not found"
+ if `which pdflatex` == ""
+ raise Error, "`pdflatex` command not found"
end
- if `which dvips` == ""
- raise Error, "`dvips` command not found"
+ if `which gs` == ""
+ raise Error, "`gs` command not found"
end
+
+ if `which pnmcrop` == ""
+ raise Error, "`pnmcrop` command not found"
+ end
- if `which convert` == ""
- raise Error, "`convert` command not found"
+ if `which pnmpad` == ""
+ raise Error, "`pnmpad` command not found"
end
- if `which gs` == ""
- raise Error, "`gs` command not found"
+ if `which pnmscale` == ""
+ raise Error, "`pnmscale` command not found"
end
+
+ if `which ppmtopgm` == ""
+ raise Error, "`ppmtopgm` command not found"
+ end
+
+ if `which pnmgamma` == ""
+ raise Error, "`pnmgamma` command not found"
+ end
+
+ if `which pnmtopng` == ""
+ raise Error, "`pnmtopng` command not found"
+ end
@dependencies_available = true
end
- def self.render_formula(formula)
+ # Render the formula and calculate the correct alignment
+ # for the image in the html.
+ #
+ # This is a ruby implementation of the Perl version described
+ # at http://tex.stackexchange.com/questions/44486/pixel-perfect-vertical-alignment-of-image-rendered-tex-snippets
+ #
+ # The main caveat is that rendering takes quite a bit of processing power,
+ # which can make the page load slowly if it has to render each time.
+ # For this reason, the method caches the rendered formula in `/tmp` for reduced
+ # loading time in subsequent loads.
+ #
+ # @param formula the tex formula to render
+ # @param with_properties, if true it returns an array with a base64
+ # string with the image, and the alignment values for the image.
+ # Otherwise it returns the binary image.
+ def self.render_formula(formula, with_properties=false)
check_dependencies!
- Dir.mktmpdir('tex') do |path|
- tex_path = ::File.join(path, 'formula.tex')
- dvi_path = ::File.join(path, 'formula.dvi')
- eps_path = ::File.join(path, 'formula.eps')
- png_path = ::File.join(path, 'formula.png')
+ render_antialias_bits = 4
+ render_oversample = 4
+ display_oversample = 4
+ gamma = 0.3
+ if !with_properties
+ display_oversample = 1
+ gamma = 0.5
+ end
- ::File.open(tex_path, 'w') { |f| f.write(Template % formula) }
+ oversample = render_oversample * display_oversample
+ render_dpi = 96*1.2 * 72.27/72 * oversample # This is 1850.112 dpi.
- result = sh latex_path, '-interaction=batchmode', 'formula.tex', :chdir => path
- raise Error, "`latex` command failed: #{result}" unless ::File.exist?(dvi_path)
- result = sh dvips_path, '-o', eps_path, '-E', dvi_path
- raise Error, "`dvips` command failed: #{result}" unless ::File.exist?(eps_path)
- result = sh convert_path, '+adjoin',
- '-antialias',
- '-transparent', 'white',
- '-density', '150x150',
- eps_path, png_path
- raise Error, "`convert` command failed: #{result}" unless ::File.exist?(png_path)
+ # Cache rendered formula and returned cached version if it exists
- ::File.read(png_path)
- end
+ # First look for the .cache directory in the home folder
+ cache_dir = ::File.expand_path("~/.cache")
+ if not ::File.exists?(cache_dir) or not ::File.directory?(cache_dir)
+ ::Dir.mkdir(cache_dir)
+ end
+
+ # Check that the gollum directory exists inside the cache dir
+ cache_dir = ::File.join(cache_dir, "gollum")
+ if not ::File.exists?(cache_dir) or not ::File.directory?(cache_dir)
+ ::Dir.mkdir(cache_dir)
+ end
+
+ # Check for the formula in the cache dir
+ hash = Digest::SHA1.hexdigest(formula)
+ cache_file = ::File.join(cache_dir, "tex-#{hash}")
+
+ if ::File.exists?(cache_file)
+ width, height, align, base64 = ::File.open(cache_file, 'rb') { |io| io.read }.split(",")
+
+ if with_properties
+ return width, height, align, base64
+ else
+ return Base64.decode64(base64)
+ end
+ end
+
+ Dir.mktmpdir('tex') do |path|
+ file = ::File.join(path, "formula")
+
+ # --- Write TeX source and compile to PDF.Write snippet into template
+ ::File.open(file + ".tex", 'w') { |f| f.write(Template % formula) }
+
+ result = sh_chdir path, "pdflatex",
+ "-halt-on-error",
+ "-output-directory=#{path}",
+ "-output-format=pdf",
+ "#{file}.tex",
+ ">#{file}.err 2>&1"
+
+
+
+ # --- Convert PDF to PNM using Ghostscript.
+ sh "gs",
+ "-q -dNOPAUSE -dBATCH",
+ "-dTextAlphaBits=#{render_antialias_bits}",
+ "-dGraphicsAlphaBits=#{render_antialias_bits}",
+ "-r#{render_dpi}",
+ "-sDEVICE=pnmraw",
+ "-sOutputFile=#{file}.pnm",
+ "#{file}.pdf"
+
+
+ img_width, img_height = pnm_width_height(file + ".pnm")
+
+
+ # --- Read dimensions file written by TeX during processing.
+ #
+ # Example of file contents:
+ # snippetdepth = 6.50009pt
+ # snippetheight = 13.53899pt
+ # snippetwidth = 145.4777pt
+ # pagewidth = 153.4777pt
+ # pageheight = 28.03908pt
+ # pagemargin = 4.0pt
+ dimensions = {}
+ ::File.open(file + ".dimensions").readlines.each_with_index do |line, i|
+ if line =~ /^(\S+)\s+=\s+(-?[0-9\.]+)pt$/
+ dimensions[$1] = Float($2) / 72.27 * render_dpi
+ else
+ raise Error, "#{file}.dimensions: invalid line: #{i}"
+ end
+ end
+
+ # --- Crop bottom, then measure how much was cropped.
+ sh "pnmcrop -white -bottom #{file}.pnm >#{file}.bottomcrop.pnm"
+ #raise Error, "`pnmcrop` command failed: #{result}" unless ::File.exist?(file + ".bottomcrop.pnm")
+
+ img_width_bottomcrop, img_height_bottomcrop = pnm_width_height("#{file}.bottomcrop.pnm")
+ bottomcrop = img_height - img_height_bottomcrop
+
+ # --- Crop top and sides, then measure how much was cropped from the top.
+ sh "pnmcrop -white #{file}.bottomcrop.pnm > #{file}.crop.pnm"
+ #raise Error, "`pnmcrop` command failed: #{result}" unless ::File.exist?(file + ".crop.pnm")
+
+ cropped_img_width, cropped_img_height = pnm_width_height("#{file}.crop.pnm")
+ topcrop = img_height_bottomcrop - cropped_img_height
+
+ # --- Pad image with specific values on all four sides, in preparation for
+ # downsampling.
+
+ # Calculate bottom padding.
+ snippet_depth = Integer(dimensions["snippetdepth"] + dimensions["pagemargin"] + 0.5) - bottomcrop
+ padded_snippet_depth = round_up(snippet_depth, oversample)
+ increase_snippet_depth = padded_snippet_depth - snippet_depth
+ bottom_padding = increase_snippet_depth
+
+ # --- Next calculate top padding, which depends on bottom padding.
+
+ padded_img_height = round_up(cropped_img_height + bottom_padding,
+ oversample)
+ top_padding = padded_img_height - (cropped_img_height + bottom_padding)
+
+
+ # --- Calculate left and right side padding. Distribute padding evenly.
+
+ padded_img_width = round_up(cropped_img_width, oversample)
+ left_padding = Integer((padded_img_width - cropped_img_width) / 2.0)
+ right_padding = (padded_img_width - cropped_img_width) - left_padding
+
+
+ # --- Pad the final image.
+ result = sh "pnmpad",
+ "-white",
+ "-bottom=#{bottom_padding}",
+ "-top=#{top_padding}",
+ "-left=#{left_padding}",
+ "-right=#{right_padding}",
+ "#{file}.crop.pnm",
+ ">#{file}.pad.pnm"
+
+ # --- Sanity check of final size.
+ final_pnm_width, final_pnm_height = pnm_width_height(file + ".pad.pnm")
+ raise Error, "#{final_pnm_width} is not a multiple of #{oversample}" unless final_pnm_width % oversample == 0
+
+ raise "#{final_pnm_height} is not a multiple of #{oversample}" unless final_pnm_height % oversample == 0
+
+ # --- Convert PNM to PNG.
+
+ final_png_width = final_pnm_width / render_oversample
+ final_png_height = final_pnm_height / render_oversample
+
+ result = sh "cat #{file}.pad.pnm",
+ "| ppmtopgm",
+ "| pnmscale -reduce #{render_oversample}",
+ "| pnmgamma #{gamma}",
+ "| pnmtopng -compression 9",
+ "> #{file}.png"
+
+ raise Error, "Conversion to png failed: #{result}" unless ::File.exist?(file + ".png")
+
+ # Calculate html properties
+ html_img_width = final_png_width / display_oversample
+ html_img_height = final_png_height / display_oversample
+ html_img_vertical_align = sprintf("%.0f", -padded_snippet_depth / oversample)
+ png_data_base64 = Base64.encode64(::File.open("#{file}.png") { |io| io.read }).chomp
+
+ ::File.open(cache_file, 'w') { |f| f.write(%{#{html_img_width},#{html_img_height},#{html_img_vertical_align},#{png_data_base64}}) }
+ if with_properties
+ return html_img_width, html_img_height, html_img_vertical_align, png_data_base64
+ else
+ ::File.read(file + ".png")
+ end
+ end
end
private
+ def self.sh_chdir(path, *args)
+ origcommand = args * " "
+ return if origcommand == ""
+
+ command = origcommand
+ command.gsub! /(["\\])/, "\\$1"
+ command = %{/bin/sh -c "(#{command}) 2>&1"}
+
+ pid = spawn command, :chdir => path
+
+ result = Process::waitpid(pid)
+ exit_value = Integer($? >> 8), signal_num = Integer($? & 127), dumped_core = Integer($? & 128)
+ raise Error, "Failed #{result}: #{origcommand}. Exit value = #{exit_value}. Signal Num = #{signal_num}. Dumped core = #{dumped_core}" unless $?.success?
+
+ return result
+ end
+
def self.sh(*args)
- pid = spawn *args
- Process::waitpid(pid)
+ origcommand = args * " "
+ return if origcommand == ""
+
+ command = origcommand
+ command.gsub! /(["\\])/, "\\$1"
+ command = %{/bin/sh -c "(#{command}) 2>&1"}
+
+ pid = spawn command
+ #pid = spawn *args
+ result = Process::waitpid(pid)
+ exit_value = $? >> 8, signal_num = $? & 127, dumped_core = $? & 128
+ raise Error, "Failed #{result}: #{origcommand}. Exit value = #{exit_value}. Signal Num = #{signal_num}. Dumped core = #{dumped_core}" unless $?.success?
+
+ return result
end
+
+ def self.round_up(num, mod)
+ num + (num % mod == 0 ? 0 : (mod - (num % mod)))
+ end
+
+ def self.pnm_width_height(filename)
+ raise Error, "#{filename} is not a .pnm file" if filename !~ /\.pnm$/
+
+ width = nil, height = nil
+ ::File.open(filename) do |file|
+ # Read first line
+ line = file.gets
+ begin
+ line = file.gets # Read next line, skipping comments
+ end while line && line =~ /^#/
+
+ if line =~ /^(\d+)\s+(\d+)$/
+ width = Integer($1)
+ height = Integer($2)
+ else
+ raise Error, "#{filename}: couldn't read image size"
+ end
+ end
+
+ raise Error, "#{filename}: couldn't read image size" unless width && height
+
+ return width, height
+ end
+
end
end