[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.
This commit is contained in:
Felipe Lalanne
2012-06-11 11:25:25 +02:00
parent 59ac86cf6e
commit 74c1ba1653
5 changed files with 343 additions and 50 deletions
+2 -2
View File
@@ -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
+1
View File
@@ -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'
+16 -1
View File
@@ -146,7 +146,22 @@ module Gollum
def process_tex(data)
@texmap.each do |id, spec|
type, tex = *spec
out = %{<img src="#{::File.join(@wiki.base_path, '_tex.png')}?type=#{type}&data=#{Base64.encode64(tex).chomp}" alt="#{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 = %{<img width="#{width}" height="#{height}" style="vertical-align: #{align}px;" src="data:image/png;base64,\n#{base64}" alt="#{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 = %{<img width="#{width}" height="#{height}" style="vertical-align: #{align}px;" src="#{::File.join(@wiki.base_path, '_tex.png')}?type=#{type}&data=#{Base64.encode64(tex).chomp}" alt="#{CGI.escapeHTML(tex)}" />}
rescue # In case of error
out = CGI.escapeHTML(tex)
end
data.gsub!(id, out)
end
data
+322 -45
View File
@@ -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
+2 -2
View File
@@ -605,13 +605,13 @@ end
test "TeX block syntax" do
content = 'a \[ a^2 \] b'
output = "<p>a<imgsrc=\"/_tex.png?type=block&data=YV4y\"alt=\"a^2\">b</p>"
output = "<p>a<imgwidth=\"15\"height=\"16\"style=\"vertical-align:-1px;\"src=\"/_tex.png?type=block&data=YV4y\"alt=\"a^2\"/>b</p>"
compare(content, output, 'md')
end
test "TeX inline syntax" do
content = 'a \( a^2 \) b'
output = "<p>a<imgsrc=\"/_tex.png?type=inline&data=YV4y\"alt=\"a^2\">b</p>"
output = "<p>a<imgwidth=\"15\"height=\"16\"style=\"vertical-align:-1px;\"src=\"/_tex.png?type=inline&data=YV4y\"alt=\"a^2\"/>b</p>"
compare(content, output, 'md')
end