From 74c1ba1653b63196133e9c614bad7a16650cfcee Mon Sep 17 00:00:00 2001 From: Felipe Lalanne Date: Mon, 11 Jun 2012 11:25:25 +0200 Subject: [PATCH] [Fix] Inline latex formula alignment Fixed an issue of latex inline formulas not aligning correctly with the text. The fix is a ruby translation of the Perl code described in http://tex.stackexchange.com/questions/44486/pixel-perfect-vertical-alignment-of-image-rendered-tex-snippets. This code calculates the alignment of the generated image depending on the image size and the size of the white space between the formula and the margins of the image. The alignment now is nearly perfect, however the new rendering comes with a performance impact because of the increased number of operations. To reduce this impact, the generated images and calculated values for the alignment are now cached in `~/.cache/gollum`. The caching is done per formula and not per page, thus avoiding caching the same formula twice. The Readme and tests have been modified accordingly with the new tool requirements and expected markup. --- README.md | 4 +- lib/gollum/frontend/app.rb | 1 + lib/gollum/markup.rb | 17 +- lib/gollum/tex.rb | 367 ++++++++++++++++++++++++++++++++----- test/test_markup.rb | 4 +- 5 files changed, 343 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 361e8b4c..63feae26 100644 --- a/README.md +++ b/README.md @@ -352,8 +352,8 @@ inline with regular text. For example: In order to get the mathematical equations rendering to work, you need the following binaries: -* LaText, TeTex or MacTex/BasicTeX (latex, dvips) -* ImageMagick (convert) +* LaTex, TeTex or MacTex/BasicTeX (pdflatex) +* Netpbm (pnmcrop, pnmpad, pnmscale, ppmtopgm, pnmgamma, pnmtopng) * Ghostscript (gs) ## SEQUENCE DIAGRAMS diff --git a/lib/gollum/frontend/app.rb b/lib/gollum/frontend/app.rb index eb432db7..3aab0d2f 100644 --- a/lib/gollum/frontend/app.rb +++ b/lib/gollum/frontend/app.rb @@ -3,6 +3,7 @@ require 'sinatra' require 'gollum' require 'mustache/sinatra' require 'useragent' +require 'time' require 'gollum/frontend/views/layout' require 'gollum/frontend/views/editable' diff --git a/lib/gollum/markup.rb b/lib/gollum/markup.rb index bcc58906..31eba876 100644 --- a/lib/gollum/markup.rb +++ b/lib/gollum/markup.rb @@ -146,7 +146,22 @@ module Gollum def process_tex(data) @texmap.each do |id, spec| type, tex = *spec - out = %{#{CGI.escapeHTML(tex)}} + + # Obtain the formula with parameters + out = nil + begin + width, height, align, base64 = Gollum::Tex.render_formula(tex, true) + + # TODO: Should we load the binary inside the html? + #out = %{#{CGI.escapeHTML(tex)}} + + # Use the alignment values from the formula rendering but still use the call to '_tex.png'. Although it will call render_formula() + # again, it will use the already cached formula and it might have some advantages from the point of view of browser caching (really not sure here). + out = %{#{CGI.escapeHTML(tex)}} + rescue # In case of error + out = CGI.escapeHTML(tex) + end + data.gsub!(id, out) end data diff --git a/lib/gollum/tex.rb b/lib/gollum/tex.rb index f85dcfc3..8511905c 100644 --- a/lib/gollum/tex.rb +++ b/lib/gollum/tex.rb @@ -2,6 +2,7 @@ require 'fileutils' require 'shellwords' require 'tmpdir' require 'posix/spawn' +require 'base64' module Gollum module Tex @@ -10,80 +11,356 @@ module Gollum 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" - end - - if `which dvips` == "" - raise Error, "`dvips` command not found" - end - - if `which convert` == "" - raise Error, "`convert` command not found" + if `which pdflatex` == "" + raise Error, "`pdflatex` command not found" end if `which gs` == "" raise Error, "`gs` command not found" end + + if `which pnmcrop` == "" + raise Error, "`pnmcrop` command not found" + end + + if `which pnmpad` == "" + raise Error, "`pnmpad` command not found" + end + + 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(*args) - pid = spawn *args - Process::waitpid(pid) + 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) + 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 diff --git a/test/test_markup.rb b/test/test_markup.rb index 331dcee7..1cbc203a 100644 --- a/test/test_markup.rb +++ b/test/test_markup.rb @@ -605,13 +605,13 @@ end test "TeX block syntax" do content = 'a \[ a^2 \] b' - output = "

ab

" + output = "

ab

" compare(content, output, 'md') end test "TeX inline syntax" do content = 'a \( a^2 \) b' - output = "

ab

" + output = "

ab

" compare(content, output, 'md') end