Pixel-perfect vertical alignment of image-rendered TeX snippets?

What is the most accurate way to automatically calculate the text baseline in an image that has been rendered from a TeX snippet—so that the rendered image can be given proper vertical alignment in a block of text?

My current approach (which isn’t working in all cases):

Here’s an example of what I’m trying to do. Note that these are screenshots of a web page (HTML + CSS + Latin Modern fonts for web) and not of a TeX document. The web page is mostly paragraphs of text, but contains embedded PNG images (rendered TeX snippets) for the formulas involving square roots. Here’s how I want it to look:

But here’s what I’m getting...

The second—the smaller—square root formula is aligned correctly, and it was done automatically. I’m calculating the baseline by first rendering a snippet consisting of only a “.” character, and then by measuring the height of the resulting image, after cropping away everything below the “.”, this tells me how much I need to lower the image (using CSS’s vertical-align style) in order to align it with the surrounding text’s baseline. This works well for formulas that aren’t too tall.

Where it fails is, well, taller formulas, as shown above. In the case of the first—the larger—square root formula, it needs to be lowered less than normal, because it extends higher than normal. My calculations for this are currently wrong, and I’m wondering how I can fix this.

Alternatives?

What are some ways of measuring the baseline (in pixels) of a snippet? I can’t really use \documentclass{standalone} for this because it crops the page as tightly as possible, which produces different image heights for $x$, $X$, and \sqrt{x}. I’m thinking I may need to render a calibration snippet consisting of two blank lines prior to a lone . (or perhaps a bottom-aligned horizontal rule) instead of just a single . character—but that seems a bit kludgey.

Is there a way to coax TeX into not placing a formula lower on the page when it is taller than standard text? That is, is there some way I can cause a formula at the top of a page to protrude upward into the top margin?

A second problem

I also noticed that I’m seeing sub-pixel alignment problems. Below are screenshots scaled up to 400% actual size: This formula is ¾ pixel too low:

At first, I thought I was calculating the vertical-align value wrong, so I manually moved it up by one pixel, but then it turns out that it's ¼ pixel too high—which means the problem lies within the image rather than the alignment value:

I suspect this is fixable by making sure I round the image heights up to the nearest multiple of 4 before I downsample them for embedding in the page. Just wondering if anyone has tackled this problem before, and has any tips. I’m encouraged by these results so far, but doing this correctly turns out to be a lot more subtle than I expected it would be. Naïvely, when I first started this, I hadn’t considered tall formulas or even vertical alignment at all.

-
"96 is the screen dpi that modern web browsers assume" -- is that why it's disastrously broken on 120 dpi/large font systems? Systems where the user has changed the default font-size to something other than 16px? –  user12153 Feb 27 '12 at 20:40
Why didn't anybody mention mathjax.org ? –  Aurélien Ooms Feb 19 at 7:20
@AurélienOoms — MathJax is nice, but it solves a different problem. This was about using TeX's rendering engine rather than leaving it up to the browser. In other words, this question is about how to achieve the highest quality alignment from (La)TeX-generated images rather than how to achieve the highest quality typography using built-in fonts. So, by definition, there is no valid answer that would include MathJax, other than simply to mention it as a completely different alternative. –  Todd Lehman Feb 19 at 19:01

Note that dvipng can determine the baseline for you, as long as you activate the 'preview' style. Here are the exact steps you need:

1. Put \usepackage[active,textmath]{preview} in your LaTeX header
2. Run LaTeX to get a dvi file, (eg myfile.dvi)
my ($num,$mod) = @_;
return $num + ($num % $mod == 0? 0 : ($mod - ($num %$mod)));
}

#------------------------------------------------------------------------------
#
#   FETCH WIDTH AND HEIGHT FROM PNM FILE
#

sub pnm_width_height ($) { my ($filename) = @_;
$filename =~ m/\.pnm$/ or die "$filename: not .pnm"; open(PNM, '<',$filename) or die "$filename: can't read"; my$line = <PNM>;  # Skip first line.
do { $line = <PNM> } while$line =~ m/^#/;  # Read next line, skipping comments
close(PNM);

my ($width,$height) = ($line =~ m/^(\d+)\s+(\d+)$/);
defined($width) && defined($height)
or die "$filename: Couldn't read image size"; return ($width, $height); } #------------------------------------------------------------------------------ # # COMPILE LATEX SNIPPET INTO HTML # # This routine caches results in the /tmp directory. Snippets are named and # indexed by their SHA-1 hash. # sub tex_to_html ($$) { my ($tex_template, $tex_snippet) = @_; my$render_antialias_bits = 4;
my $render_oversample = 4; my$display_oversample = 4;
my $oversample =$render_oversample * $display_oversample; my$render_dpi = 96*1.2 * 72.27/72 * $oversample; # This is 1850.112 dpi. # --- Generate SHA-1 hash of TeX input for caching. (my$tex_input = $tex_template) =~ s{<SNIPPET>}{$tex_snippet};
my $hash = do { use Digest::SHA; uc(Digest::SHA::sha1_hex($tex_input)); };
my $file = "/tmp/tex-$hash";

# --- If the image has already been compiled, then simply return the
#     cached result.  Otherwise, continue and create the image.

if (open(HTML, '<', "$file.html")) { my$html = do { local $/; <HTML> }; close(HTML); return$html;
}

# --- Write TeX source and compile to PDF.

open(TEX, '>', "$file.tex") and print TEX$tex_input and close(TEX)
or die "$file.tex: can't write"; run_command( "pdflatex", "-halt-on-error", "-output-directory=/tmp", "-output-format=pdf", "$file.tex",
">$file.err 2>&1" ); # --- Convert PDF to PNM using Ghostscript. run_command( "gs", "-q -dNOPAUSE -dBATCH", "-dTextAlphaBits=$render_antialias_bits",
"-dGraphicsAlphaBits=$render_antialias_bits", "-r$render_dpi",
"-sDEVICE=pnmraw",
"-sOutputFile=$file.pnm", "$file.pdf"
);

my ($img_width,$img_height) = pnm_width_height("$file.pnm"); #print "# img_width=$img_width\n";
#print "# img_height=$img_height\n"; #print "# \n"; # --- 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 my$dimensions = {};
do {
open(DIMENSIONS, '<', "$file.dimensions") or die "$file.dimensions: can't read";
while (<DIMENSIONS>) {
if (m/^(\S+)\s+=\s+(-?[0-9\.]+)pt$/) { my ($value, $length) = ($1, $2);$length = $length / 72.27 *$render_dpi;
$dimensions->{$value} = $length; } else { die "$file.dimensions: invalid line: $_"; } } close(DIMENSIONS); }; #foreach (keys %$dimensions) { print "# $_=$dimensions->{$_}px\n"; } #print "# \n"; # --- Crop bottom, then measure how much was cropped. run_command("pnmcrop -white -bottom$file.pnm >$file.bottomcrop.pnm"); my ($img_width_bottomcrop, $img_height_bottomcrop) = pnm_width_height("$file.bottomcrop.pnm");

my $bottomcrop =$img_height - $img_height_bottomcrop; #printf "# Cropping bottom: %d pixels - %d pixels = %d pixels cropped\n", #$img_height, $img_height_bottomcrop,$bottomcrop;

# --- Crop top and sides, then measure how much was cropped from the top.

run_command("pnmcrop -white $file.bottomcrop.pnm >$file.crop.pnm");

my ($cropped_img_width,$cropped_img_height) =
pnm_width_height("$file.crop.pnm"); my$topcrop = $img_height_bottomcrop -$cropped_img_height;
#printf "# Cropping top:  %d pixels - %d pixels = %d pixels cropped\n",
#    $img_height_bottomcrop,$cropped_img_height, $topcrop; # --- Pad image with specific values on all four sides, in preparation for # downsampling. # Calculate bottom padding. my$snippet_depth =
int($dimensions->{snippetdepth} +$dimensions->{pagemargin} + .5)
- $bottomcrop; my$padded_snippet_depth = round_up($snippet_depth,$oversample);
my $increase_snippet_depth =$padded_snippet_depth - $snippet_depth; my$bottom_padding = $increase_snippet_depth; #printf "# Padding snippet depth: %d pixels + %d pixels = %d pixels\n", #$snippet_depth, $increase_snippet_depth,$padded_snippet_depth;

my $padded_img_height = round_up($cropped_img_height + $bottom_padding,$oversample);
my $top_padding =$padded_img_height - ($cropped_img_height +$bottom_padding);
#printf "# Padding top:  %d pixels + %d pixels = %d pixels\n",
#    $cropped_img_height,$top_padding, $padded_img_height; # --- Calculate left and right side padding. Distribute padding evenly. my$padded_img_width = round_up($cropped_img_width,$oversample);
my $left_padding = int(($padded_img_width - $cropped_img_width) / 2); my$right_padding = ($padded_img_width -$cropped_img_width)
- $left_padding; #printf "# Padding left =$left_padding pixels\n";
#printf "# Padding right = $right_padding pixels\n"; # --- Pad the final image. run_command( "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. my ($final_pnm_width, $final_pnm_height) = pnm_width_height("$file.pad.pnm");
$final_pnm_width %$oversample == 0
or die "$final_pnm_width is not a multiple of$oversample";
$final_pnm_height %$oversample == 0
or die "$final_pnm_height is not a multiple of$oversample";

# --- Convert PNM to PNG.

my $final_png_width =$final_pnm_width  / $render_oversample; my$final_png_height = $final_pnm_height /$render_oversample;

run_command(
"cat $file.pad.pnm", "| ppmtopgm", "| pamscale -reduce$render_oversample",
"| pnmgamma .3",
"| pnmtopng -compression=9",
"> $file.png" ); # --- Convert PNG to HTML. my$html_img_width  = $final_png_width /$display_oversample;
my $html_img_height =$final_png_height / $display_oversample; my$html_img_vertical_align = sprintf("%.0f",
-$padded_snippet_depth /$oversample);

(my $html_img_title =$tex_snippet) =~
s{([&<>'"])}{sprintf("&#%d;",ord($1))}eg; my$png_data_base64 = do {
open(PNG, '<', "$file.png") or die "$file.png: can't open";
binmode PNG;
my $png_data = do { local$/; <PNG> };
close(PNG);
use MIME::Base64;
MIME::Base64::encode_base64($png_data); }; #$png_data_base64 =~ s/\s+//g;

my $html = qq{<img\n} . qq{ width=$html_img_width} .
qq{ height=html_img_height} . qq{ style="vertical-align:{html_img_vertical_align}px;"} .
qq{ title="$html_img_title"} . qq{ src="data:image/png;base64,\n$png_data_base64" />};

open(HTML, '>', "$file.html") and print HTML$html and close(HTML)
or die "$file.html: can't write"; # --- Clean up and return result to caller. run_command( "rm -f", "${file}{.*,}.{tex,aux,dvi,err,log,dimensions,pdf,pnm,png}"
);

return $html; } #------------------------------------------------------------------------------ # # MAIN CONTROL # binmode(STDIN, ":utf8"); binmode(STDOUT, ":utf8"); binmode(STDERR, ":utf8"); my$tex_template = do { local $/; <DATA> }; my$input = do { local $/; <STDIN> }; (my$html = $input) =~ s{\$(.*?)\$}{tex_to_html($tex_template,$1)}seg;$html =~ s{([^\s<>]*<img.*?>[^\s<>]*)}
{<span style="white-space:nowrap;">$1</span>}sg; print <<EOT; <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <title></title> </head> <body> <p>$html
</p>
</body>
</html>
EOT

exit(0);

#------------------------------------------------------------------------------
#
#   LATEX TEMPLATE
#

__DATA__
\documentclass[10pt]{article}
\pagestyle{empty}
\setlength{\topskip}{0pt}
\setlength{\parindent}{0pt}
\setlength{\abovedisplayskip}{0pt}
\setlength{\belowdisplayskip}{0pt}

\usepackage{geometry}

\usepackage{amsmath}

\newsavebox{\snippetbox}
\newlength{\snippetwidth}
\newlength{\snippetheight}
\newlength{\snippetdepth}
\newlength{\pagewidth}
\newlength{\pageheight}
\newlength{\pagemargin}

\begin{lrbox}{\snippetbox}%
$<SNIPPET>$%
\end{lrbox}

\settowidth{\snippetwidth}{\usebox{\snippetbox}}
\settoheight{\snippetheight}{\usebox{\snippetbox}}
\settodepth{\snippetdepth}{\usebox{\snippetbox}}

\setlength\pagemargin{4pt}

\setlength\pagewidth\snippetwidth

\setlength\pageheight\snippetheight

\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}


Update to code: I just added -compression=9 to the pnmtopng command line and added ppmtopgm (convert to grayscale) in the final conversion pipeline. These together reduce the PNG image sizes by 20%. By the way, the average file size of the 24 PNG images in the sample screenshots shown above is 3534.83 bytes. The HTML document is 120,053 bytes. Keep in mind that these PNG images are 4 times larger (in each dimension height and width) than what appears on the screen at the default font size. If display-time oversampling is disabled, then the PNG images average 732.8 bytes each and the HTML document goes down to 29,209 bytes. I’m not particularly worried about HTML and image file sizes anymore like I was in the 1990s, but I thought this was worth noting anyway. (Note: A pnmgamma adjustment of .5 or so should be used instead of .3 if display-time oversampling is diabled.)

-
Bravo! Impressive work. Looks great!! To clarify, this is base64 encoded PNGs embedded in the HTML? (Judging by the code) –  MercurialMadnessMan Feb 25 '12 at 7:21
@MercurialMadnessMan: Yup! it is base64-encoded PNG files embedded directly into the produced HTML document. Thus, the .html file is fully encapulsated, with no external references. –  Todd Lehman Feb 25 '12 at 7:43
It's written as a pipe: Pipe the input on stdin, then redirect stdout to a file. :) –  Todd Lehman Feb 26 '12 at 10:13
Cool stuff! On my ubuntu box i had to modify the script a little bit (here is the diff): 274c274 > "| pnmtopng -compression=9", --- < "| pnmtopng -compression 9",  It seems that my version of pnmtopng doesn't like the equal sign. After fixing this it worked perfectly! –  user12149 Feb 27 '12 at 19:07
@GüntherHutter: Interesting. That's surprising because the non-= form isn't even mentioned in the user manual. [netpbm.sourceforge.net/doc/pnmtopng.html] In any case, I'm glad it worked for you! –  Todd Lehman Feb 28 '12 at 4:43

Have a look at the ideas at The baseline problem corrected. They have been incorporated into mathTeX vertical alignment if you want to use mathTeX to create the images.

-
This is helping a lot, @stevem — thanks. I’m about halfway done with implementing the ideas set forth in the Mac OS X TeX Toolbox link you provided. I also figured out some improvements to their techniques; I’ll post a full solution when it’s ready. –  Todd Lehman Feb 18 '12 at 8:37
90% there. Now have an implementation which lines up absolutely perfectly! To do it correctly, though, is rather complicated: Calculations involve the ratio 72.27/72 and result in a rendering resolution (using GhostScript) of precisely 1850.112 dpi. The image has to be padded with a small margin, then first cropped on the bottom, remeasured, then re-padded on the bottom, and then padded on the top and sides. The top padding is dependent on the bottom padding. A small amount of final subpixel padding is necessary for downsampling on the screen. –  Todd Lehman Feb 18 '12 at 11:49