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