##// END OF EJS Templates
Better color options and added scale
Oscar Gustafsson -
Show More
@@ -1,203 +1,220 b''
1 1 # -*- coding: utf-8 -*-
2 2 """Tools for handling LaTeX."""
3 3
4 4 # Copyright (c) IPython Development Team.
5 5 # Distributed under the terms of the Modified BSD License.
6 6
7 7 from io import BytesIO, open
8 8 import os
9 9 import tempfile
10 10 import shutil
11 11 import subprocess
12 12 from base64 import encodebytes
13 from textwrap import wrap as splitstring
13 14
14 15 from IPython.utils.process import find_cmd, FindCmdError
15 16 from traitlets.config import get_config
16 17 from traitlets.config.configurable import SingletonConfigurable
17 18 from traitlets import List, Bool, Unicode
18 19 from IPython.utils.py3compat import cast_unicode
19 20
20 21
21 22 class LaTeXTool(SingletonConfigurable):
22 23 """An object to store configuration of the LaTeX tool."""
23 24 def _config_default(self):
24 25 return get_config()
25 26
26 27 backends = List(
27 28 Unicode(), ["matplotlib", "dvipng"],
28 29 help="Preferred backend to draw LaTeX math equations. "
29 30 "Backends in the list are checked one by one and the first "
30 31 "usable one is used. Note that `matplotlib` backend "
31 32 "is usable only for inline style equations. To draw "
32 33 "display style equations, `dvipng` backend must be specified. ",
33 34 # It is a List instead of Enum, to make configuration more
34 35 # flexible. For example, to use matplotlib mainly but dvipng
35 36 # for display style, the default ["matplotlib", "dvipng"] can
36 37 # be used. To NOT use dvipng so that other repr such as
37 38 # unicode pretty printing is used, you can use ["matplotlib"].
38 39 ).tag(config=True)
39 40
40 41 use_breqn = Bool(
41 42 True,
42 43 help="Use breqn.sty to automatically break long equations. "
43 44 "This configuration takes effect only for dvipng backend.",
44 45 ).tag(config=True)
45 46
46 47 packages = List(
47 48 ['amsmath', 'amsthm', 'amssymb', 'bm'],
48 49 help="A list of packages to use for dvipng backend. "
49 50 "'breqn' will be automatically appended when use_breqn=True.",
50 51 ).tag(config=True)
51 52
52 53 preamble = Unicode(
53 54 help="Additional preamble to use when generating LaTeX source "
54 55 "for dvipng backend.",
55 56 ).tag(config=True)
56 57
57 58
58 def latex_to_png(s, encode=False, backend=None, wrap=False, color='Black'):
59 def latex_to_png(s, encode=False, backend=None, wrap=False, color='Black',
60 scale=1.0):
59 61 """Render a LaTeX string to PNG.
60 62
61 63 Parameters
62 64 ----------
63 65 s : str
64 66 The raw string containing valid inline LaTeX.
65 67 encode : bool, optional
66 68 Should the PNG data base64 encoded to make it JSON'able.
67 69 backend : {matplotlib, dvipng}
68 70 Backend for producing PNG data.
69 71 wrap : bool
70 72 If true, Automatically wrap `s` as a LaTeX equation.
71 73 color : string
72 Foreground color name among dvipsnames.
74 Foreground color name among dvipsnames, e.g. 'Maroon' or on hex RGB
75 format, e.g. '#AA20FA'.
76 scale : float
77 Scale factor for the resulting PNG.
73 78
74 79 None is returned when the backend cannot be used.
75 80
76 81 """
77 82 s = cast_unicode(s)
78 83 allowed_backends = LaTeXTool.instance().backends
79 84 if backend is None:
80 85 backend = allowed_backends[0]
81 86 if backend not in allowed_backends:
82 87 return None
83 88 if backend == 'matplotlib':
84 89 f = latex_to_png_mpl
85 90 elif backend == 'dvipng':
86 91 f = latex_to_png_dvipng
92 if color.startswith('#'):
93 # Convert hex RGB color to LaTeX RGB color.
94 if len(color) == 7:
95 try:
96 color = "RGB {}".format(" ".join([str(int(x, 16)) for x in
97 splitstring(color[1:], 2)]))
98 except ValueError:
99 raise ValueError('Invalid color specification {}.'.format(color))
100 else:
101 raise ValueError('Invalid color specification {}.'.format(color))
87 102 else:
88 103 raise ValueError('No such backend {0}'.format(backend))
89 bin_data = f(s, wrap, color)
104 bin_data = f(s, wrap, color, scale)
90 105 if encode and bin_data:
91 106 bin_data = encodebytes(bin_data)
92 107 return bin_data
93 108
94 109
95 def latex_to_png_mpl(s, wrap, color='Black'):
110 def latex_to_png_mpl(s, wrap, color='Black', scale=1.0):
96 111 try:
97 112 from matplotlib import mathtext
98 113 from pyparsing import ParseFatalException
99 114 except ImportError:
100 115 return None
101 116
102 117 # mpl mathtext doesn't support display math, force inline
103 118 s = s.replace('$$', '$')
104 119 if wrap:
105 120 s = u'${0}$'.format(s)
106 121
107 122 try:
108 123 mt = mathtext.MathTextParser('bitmap')
109 124 f = BytesIO()
110 mt.to_png(f, s, fontsize=12, color=color)
125 dpi = 120*scale
126 mt.to_png(f, s, fontsize=12, dpi=dpi, color=color)
111 127 return f.getvalue()
112 128 except (ValueError, RuntimeError, ParseFatalException):
113 129 return None
114 130
115 131
116 def latex_to_png_dvipng(s, wrap, color='Black'):
132 def latex_to_png_dvipng(s, wrap, color='Black', scale=1.0):
117 133 try:
118 134 find_cmd('latex')
119 135 find_cmd('dvipng')
120 136 except FindCmdError:
121 137 return None
122 138 try:
123 139 workdir = tempfile.mkdtemp()
124 140 tmpfile = os.path.join(workdir, "tmp.tex")
125 141 dvifile = os.path.join(workdir, "tmp.dvi")
126 142 outfile = os.path.join(workdir, "tmp.png")
127 143
128 144 with open(tmpfile, "w", encoding='utf8') as f:
129 145 f.writelines(genelatex(s, wrap))
130 146
131 147 with open(os.devnull, 'wb') as devnull:
132 148 subprocess.check_call(
133 149 ["latex", "-halt-on-error", "-interaction", "batchmode", tmpfile],
134 150 cwd=workdir, stdout=devnull, stderr=devnull)
135 151
152 resolution = round(150*scale)
136 153 subprocess.check_call(
137 ["dvipng", "-T", "tight", "-x", "1500", "-z", "9",
154 ["dvipng", "-T", "tight", "-D", str(resolution), "-z", "9",
138 155 "-bg", "transparent", "-o", outfile, dvifile, "-fg", color],
139 156 cwd=workdir, stdout=devnull, stderr=devnull)
140 157
141 158 with open(outfile, "rb") as f:
142 159 return f.read()
143 160 except subprocess.CalledProcessError:
144 161 return None
145 162 finally:
146 163 shutil.rmtree(workdir)
147 164
148 165
149 166 def kpsewhich(filename):
150 167 """Invoke kpsewhich command with an argument `filename`."""
151 168 try:
152 169 find_cmd("kpsewhich")
153 170 proc = subprocess.Popen(
154 171 ["kpsewhich", filename],
155 172 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
156 173 (stdout, stderr) = proc.communicate()
157 174 return stdout.strip().decode('utf8', 'replace')
158 175 except FindCmdError:
159 176 pass
160 177
161 178
162 179 def genelatex(body, wrap):
163 180 """Generate LaTeX document for dvipng backend."""
164 181 lt = LaTeXTool.instance()
165 182 breqn = wrap and lt.use_breqn and kpsewhich("breqn.sty")
166 183 yield r'\documentclass{article}'
167 184 packages = lt.packages
168 185 if breqn:
169 186 packages = packages + ['breqn']
170 187 for pack in packages:
171 188 yield r'\usepackage{{{0}}}'.format(pack)
172 189 yield r'\pagestyle{empty}'
173 190 if lt.preamble:
174 191 yield lt.preamble
175 192 yield r'\begin{document}'
176 193 if breqn:
177 194 yield r'\begin{dmath*}'
178 195 yield body
179 196 yield r'\end{dmath*}'
180 197 elif wrap:
181 198 yield u'$${0}$$'.format(body)
182 199 else:
183 200 yield body
184 201 yield u'\\end{document}'
185 202
186 203
187 204 _data_uri_template_png = u"""<img src="data:image/png;base64,%s" alt=%s />"""
188 205
189 206 def latex_to_html(s, alt='image'):
190 207 """Render LaTeX to HTML with embedded PNG data using data URIs.
191 208
192 209 Parameters
193 210 ----------
194 211 s : str
195 212 The raw string containing valid inline LateX.
196 213 alt : str
197 214 The alt text to use for the HTML.
198 215 """
199 216 base64_data = latex_to_png(s, encode=True).decode('ascii')
200 217 if base64_data:
201 218 return _data_uri_template_png % (base64_data, alt)
202 219
203 220
@@ -1,157 +1,181 b''
1 1 # encoding: utf-8
2 2 """Tests for IPython.utils.path.py"""
3 3
4 4 # Copyright (c) IPython Development Team.
5 5 # Distributed under the terms of the Modified BSD License.
6 6 from unittest.mock import patch
7 7 import nose.tools as nt
8 8
9 9 from IPython.lib import latextools
10 10 from IPython.testing.decorators import onlyif_cmds_exist, skipif_not_matplotlib
11 11 from IPython.utils.process import FindCmdError
12 12
13 13
14 14 def test_latex_to_png_dvipng_fails_when_no_cmd():
15 15 """
16 16 `latex_to_png_dvipng` should return None when there is no required command
17 17 """
18 18 for command in ['latex', 'dvipng']:
19 19 yield (check_latex_to_png_dvipng_fails_when_no_cmd, command)
20 20
21 21
22 22 def check_latex_to_png_dvipng_fails_when_no_cmd(command):
23 23 def mock_find_cmd(arg):
24 24 if arg == command:
25 25 raise FindCmdError
26 26
27 27 with patch.object(latextools, "find_cmd", mock_find_cmd):
28 28 nt.assert_equal(latextools.latex_to_png_dvipng("whatever", True),
29 29 None)
30 30
31 31
32 32 @onlyif_cmds_exist('latex', 'dvipng')
33 33 def test_latex_to_png_dvipng_runs():
34 34 """
35 35 Test that latex_to_png_dvipng just runs without error.
36 36 """
37 37 def mock_kpsewhich(filename):
38 38 nt.assert_equal(filename, "breqn.sty")
39 39 return None
40 40
41 41 for (s, wrap) in [(u"$$x^2$$", False), (u"x^2", True)]:
42 42 yield (latextools.latex_to_png_dvipng, s, wrap)
43 43
44 44 with patch.object(latextools, "kpsewhich", mock_kpsewhich):
45 45 yield (latextools.latex_to_png_dvipng, s, wrap)
46 46
47 47 @skipif_not_matplotlib
48 48 def test_latex_to_png_mpl_runs():
49 49 """
50 50 Test that latex_to_png_mpl just runs without error.
51 51 """
52 52 def mock_kpsewhich(filename):
53 53 nt.assert_equal(filename, "breqn.sty")
54 54 return None
55 55
56 56 for (s, wrap) in [("$x^2$", False), ("x^2", True)]:
57 57 yield (latextools.latex_to_png_mpl, s, wrap)
58 58
59 59 with patch.object(latextools, "kpsewhich", mock_kpsewhich):
60 60 yield (latextools.latex_to_png_mpl, s, wrap)
61 61
62 62 @skipif_not_matplotlib
63 63 def test_latex_to_html():
64 64 img = latextools.latex_to_html("$x^2$")
65 65 nt.assert_in("data:image/png;base64,iVBOR", img)
66 66
67 67
68 68 def test_genelatex_no_wrap():
69 69 """
70 70 Test genelatex with wrap=False.
71 71 """
72 72 def mock_kpsewhich(filename):
73 73 assert False, ("kpsewhich should not be called "
74 74 "(called with {0})".format(filename))
75 75
76 76 with patch.object(latextools, "kpsewhich", mock_kpsewhich):
77 77 nt.assert_equal(
78 78 '\n'.join(latextools.genelatex("body text", False)),
79 79 r'''\documentclass{article}
80 80 \usepackage{amsmath}
81 81 \usepackage{amsthm}
82 82 \usepackage{amssymb}
83 83 \usepackage{bm}
84 84 \pagestyle{empty}
85 85 \begin{document}
86 86 body text
87 87 \end{document}''')
88 88
89 89
90 90 def test_genelatex_wrap_with_breqn():
91 91 """
92 92 Test genelatex with wrap=True for the case breqn.sty is installed.
93 93 """
94 94 def mock_kpsewhich(filename):
95 95 nt.assert_equal(filename, "breqn.sty")
96 96 return "path/to/breqn.sty"
97 97
98 98 with patch.object(latextools, "kpsewhich", mock_kpsewhich):
99 99 nt.assert_equal(
100 100 '\n'.join(latextools.genelatex("x^2", True)),
101 101 r'''\documentclass{article}
102 102 \usepackage{amsmath}
103 103 \usepackage{amsthm}
104 104 \usepackage{amssymb}
105 105 \usepackage{bm}
106 106 \usepackage{breqn}
107 107 \pagestyle{empty}
108 108 \begin{document}
109 109 \begin{dmath*}
110 110 x^2
111 111 \end{dmath*}
112 112 \end{document}''')
113 113
114 114
115 115 def test_genelatex_wrap_without_breqn():
116 116 """
117 117 Test genelatex with wrap=True for the case breqn.sty is not installed.
118 118 """
119 119 def mock_kpsewhich(filename):
120 120 nt.assert_equal(filename, "breqn.sty")
121 121 return None
122 122
123 123 with patch.object(latextools, "kpsewhich", mock_kpsewhich):
124 124 nt.assert_equal(
125 125 '\n'.join(latextools.genelatex("x^2", True)),
126 126 r'''\documentclass{article}
127 127 \usepackage{amsmath}
128 128 \usepackage{amsthm}
129 129 \usepackage{amssymb}
130 130 \usepackage{bm}
131 131 \pagestyle{empty}
132 132 \begin{document}
133 133 $$x^2$$
134 134 \end{document}''')
135 135
136 136
137 @skipif_not_matplotlib
138 @onlyif_cmds_exist('latex', 'dvipng')
137 139 def test_latex_to_png_color():
138 140 """
139 141 Test color settings for latex_to_png.
140 142 """
141 143 latex_string = "$x^2$"
142 144 default_value = latextools.latex_to_png(latex_string, wrap=False)
145 default_hexblack = latextools.latex_to_png(latex_string, wrap=False,
146 color='#000000')
143 147 dvipng_default = latextools.latex_to_png_dvipng(latex_string, False)
144 148 dvipng_black = latextools.latex_to_png_dvipng(latex_string, False, 'Black')
145 149 nt.assert_equal(dvipng_default, dvipng_black)
146 150 mpl_default = latextools.latex_to_png_mpl(latex_string, False)
147 151 mpl_black = latextools.latex_to_png_mpl(latex_string, False, 'Black')
148 152 nt.assert_equal(mpl_default, mpl_black)
149 153 nt.assert_in(default_value, [dvipng_black, mpl_black])
154 nt.assert_in(default_hexblack, [dvipng_black, mpl_black])
150 155
151 156 # Test that dvips name colors can be used without error
152 dvipng_maroon = latextools.latex_to_png_dvipng(latex_string, False, 'Maroon')
157 dvipng_maroon = latextools.latex_to_png_dvipng(latex_string, False,
158 'Maroon')
153 159 # And that it doesn't return the black one
154 160 nt.assert_not_equal(dvipng_black, dvipng_maroon)
155 161
156 162 mpl_maroon = latextools.latex_to_png_mpl(latex_string, False, 'Maroon')
157 163 nt.assert_not_equal(mpl_black, mpl_maroon)
164 mpl_white = latextools.latex_to_png_mpl(latex_string, False, 'White')
165 mpl_hexwhite = latextools.latex_to_png_mpl(latex_string, False, '#FFFFFF')
166 nt.assert_equal(mpl_white, mpl_hexwhite)
167
168 mpl_white_scale = latextools.latex_to_png_mpl(latex_string, False,
169 'White', 1.2)
170 nt.assert_not_equal(mpl_white, mpl_white_scale)
171
172
173 def test_latex_to_png_invalid_hex_colors():
174 """
175 Test that invalid hex colors provided to dvipng gives an exception.
176 """
177 latex_string = "$x^2$"
178 nt.assert_raises(ValueError, lambda: latextools.latex_to_png(latex_string,
179 backend='dvipng', color="#f00bar"))
180 nt.assert_raises(ValueError, lambda: latextools.latex_to_png(latex_string,
181 backend='dvipng', color="#f00"))
General Comments 0
You need to be logged in to leave comments. Login now