summaryrefslogtreecommitdiff
path: root/support/gladtex/gleetex/image.py
blob: 12c92761adc70e70f3046902af9bce06775c856a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# (c) 2013-2018 Sebastian Humenda
# This code is licenced under the terms of the LGPL-3+, see the file COPYING for
# more details.
"""This module takes care of the actual image creation process.

Each formula is saved as an image, either as PNG or SVG. SVG is advised, since
it is a properly scalable format.
"""

import enum
import os
import re
import shutil
import subprocess
import sys

from .typesetting import LaTeXDocument

DVIPNG_REGEX = re.compile(r"^ depth=(-?\d+) height=(\d+) width=(\d+)")
DVISVGM_DEPTH_REGEX = re.compile(r"^\s*width=.*?pt, height=.*?pt, depth=(.*?)pt")
DVISVGM_SIZE_REGEX = re.compile(r"^\s*graphic size: (.*?)pt x (.*?)pt")

def remove_all(*files):
    """Guarded remove of files (rm -f); no exception is thrown if a file
    couldn't be removed."""
    for file in files:
        try:
            os.remove(file)
        except OSError:
            pass


def proc_call(cmd, cwd=None, install_recommends=True):
    """Execute cmd (list of arguments) as a subprocess. Returned is a tuple with
    stdout and stderr, decoded if not None. If the return value is not equal 0, a
    subprocess error is raised. Timeouts will happen after 20 seconds."""
    with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            bufsize=1, universal_newlines=False, cwd=cwd) as proc:
        data = []
        try:
            data = [d.decode(sys.getdefaultencoding(), errors="surrogateescape")
                    for d in proc.communicate(timeout=20) if d]
            if proc.wait():
                raise subprocess.SubprocessError("Error while executing %s\n%s\n" %
                    (' '.join(cmd), '\n'.join(data)))
        except subprocess.TimeoutExpired as e:
            proc.kill()
            note = 'Subprocess expired with time out: ' + str(cmd) + '\n'
            poll = proc.poll()
            if poll:
                note += str(poll) + '\n'
            if data:
                raise subprocess.SubprocessError(str(data + '\n' + note))
            else:
                raise subprocess.SubprocessError('execution timed out after ' +
                        str(e.args[1]) + ' s: ' + ' '.join(e.args[0]))
        except KeyboardInterrupt as e:
            sys.stderr.write("\nInterrupted; ")
            import traceback
            traceback.print_exc(file=sys.stderr)
        except FileNotFoundError:
            # program missing, try to help
            text = "Command `%s` not found." % cmd[0]
            if install_recommends and shutil.which('dpkg'):
                text += ' Install it using `sudo apt install ' + install_recommends
            else:
                text += ' Install a TeX distribution of your choice, e.g. MikTeX or TeXlive.'
            raise subprocess.SubprocessError(text) from None
        if isinstance(data, list):
            return '\n'.join(data)
        return data

#pylint: disable=too-few-public-methods
class Format(enum.Enum):
    """Chose the image output format."""
    Png = 'png'
    Svg = 'svg'

class Tex2img:
    """Convert a TeX document string into a png file.
    This class interacts with the LaTeX and dvipng sub processes. Upon error
    the methods throw a SubprocessError with all necessary information to fix
    the issue.

    The background of the PNG files will be transparent by default. If you set a
    background colour within the LaTeX document, you need to turn off
    transparency in this converter manually."""
    def __init__(self, fmt, encoding="UTF-8"):
        if not isinstance(fmt, Format):
            raise ValueError("Enumeration of type Format expected."+str(fmt))
        self.__format = fmt
        self.__encoding = encoding
        self.__parsed_data = None
        self.__size = [115, None]
        self.__background = 'transparent'
        self.__keep_latex_source = False

    def set_dpi(self, dpi):
        """Set output resolution for formula images. This has no effect ifthe
        output format is SVG. It will automatically overwrite a font size, if
        set."""
        if not isinstance(dpi, (int, float)):
            raise TypeError("Dpi must be an integer or floating point number")
        self.__size[0] = int(dpi)

    def set_fontsize(self, size):
        """Set font size for formulas. This will be automatically translated
        into a DPI resolution for PNG images and taken literally for SVG
        graphics."""
        if not isinstance(size, (int, float)):
            raise TypeError("Dpi must be an integer or floating point number")
        self.__size[1] = float(size)

    def set_transparency(self, flag):
        """Set whether or not to use background colour information from the DVI
        file. This is only relevant for PNG output and if a background colour
        other than "transparent" is required, in this case this set'r should be
        set to false. It is set to True, resulting in a transparent
        background."""
        self.__background = ('transparent' if flag else 'not transparent')
    def set_keep_latex_source(self, flag):
        """Set whether LaTeX source document should be kept."""
        if not isinstance(flag, bool):
            raise TypeError("boolean object required, got %s." % repr(flag))
        self.__keep_latex_source = flag


    def create_dvi(self, tex_document, dvi_fn):
        """Call LaTeX to produce a dvi file with the given LaTeX document.
        Temporary files will be removed, even in the case of a LaTeX error.
        This method raises a SubprocessError with the helpful part of LaTeX's
        error output."""
        path = os.path.dirname(dvi_fn)
        if path and not os.path.exists(path):
            os.makedirs(path)
        if not path:
            path = os.getcwd()
        new_extension = lambda x: os.path.splitext(dvi_fn)[0] + '.' + x

        if self.__size[1]: # font size in pt
            tex_document.set_fontsize(self.__size[1])
        tex_fn = new_extension('tex')
        aux_fn = new_extension('aux')
        log_fn = new_extension('log')
        cmd = None
        encoding = self.__encoding
        with open(tex_fn, mode='w', encoding=encoding) as tex:
            tex.write(str(tex_document))
        cmd = ['latex', '-halt-on-error', os.path.basename(tex_fn)]
        try:
            proc_call(cmd, cwd=path, install_recommends='texlive-recommended')
        except subprocess.SubprocessError as e:
            remove_all(dvi_fn)
            msg = ''
            if e.args:
                data = self.parse_latex_log(e.args[0])
                if data:
                    msg += data
                else:
                    msg += str(e.args[0])
            raise subprocess.SubprocessError(msg) # propagate subprocess error
        finally:
            if self.__keep_latex_source:
                remove_all(aux_fn, log_fn)
            else:
                remove_all(tex_fn, aux_fn, log_fn)

    def create_image(self, dvi_fn):
        """Create the image containing the formula, using either dvisvgm or
        dvipng."""
        dirname = os.path.dirname(dvi_fn)
        if dirname and not os.path.exists(dirname):
            os.makedirs(dirname)

        output_fn = '%s.%s' % (os.path.splitext(dvi_fn)[0], self.__format.value)
        if self.__format == Format.Png:
            dpi = (fontsize2dpi(self.__size[1])  if self.__size[1]
                    else self.__size[0])
            return create_png(dvi_fn, output_fn,dpi,
                    self.__background)
        if not self.__size[1]:
            self.__size[1] = 12 # 12 pt
        return create_svg(dvi_fn, output_fn)

    def convert(self, tex_document, base_name):
        """Convert the given TeX document into an image. The base name is used
        to create the required intermediate files and the resulting file will be
        made of the base_name and the format-specific file extension.
        This function returns the positioning information used in the CSS style
        attribute."""
        if not isinstance(tex_document, LaTeXDocument):
            raise TypeError(("expected object of type typesetting.LaTeXDocument,"
                    " got %s") % type(tex_document))
        dvi = '%s.dvi' % base_name
        try:
            self.create_dvi(tex_document, dvi)
            return self.create_image(dvi)
        except OSError:
            remove_all('%s.%s' % (base_name, self.__format.value))
            raise

    def parse_latex_log(self, logdata):
        """Parse the LaTeX error output and return the relevant part of it."""
        if not logdata:
            return None
        line = None
        for line in logdata.split('\n'):
            if line.startswith('! '):
                line = line[2:]
                break
        if line: # try to remove LaTeX line numbers
            lineno = re.search(r'\s*on input line \d+', line)
            if lineno:
                line = line[:lineno.span()[0]] + line[lineno.span()[1]:]
            return line
        return None

def fontsize2dpi(size_pt):
    """This function calculates the DPI for the resulting image. Depending on
    the font size, a different resolution needs to be used. According to the
    dvipng manual page, the formula is:
    <dpi> = <font_px> * 72.27 / 10 [px * TeXpt/in / TeXpt]"""
    size_px = size_pt * 1.3333333 # and more 3s!
    return size_px * 72.27 / 10

def create_png(dvi_fn, output_name, dpi, background):
    """Create a PNG file from a given dvi file. The side effect is the PNG file
    being written to disk.
    By default, the background of the resulting image is transparent, setting
    any other value will make it use whatever was is set in the DVI file.
    :param dvi_fn       Dvi file name
    :param output_name  Output file name
    :param dpi          Output resolution
    :param background   Background colour (default: transparent)
    :return dimensions for embedding into an HTML document
    :raises ValueError raised whenever dvipng output coudln't be parsed"""
    if not output_name:
        raise ValueError("Empty output_name")
    cmd = ['dvipng', '-q*', '-D', str(dpi)]
    if background == 'transparent':
        cmd += ['-bg', background]
    cmd += ['--height*', '--depth*', '--width*', # print information for embedding
            '-o', output_name, dvi_fn]
    data = None
    try:
        data = proc_call(cmd, install_recommends='dvipng')
    except subprocess.SubprocessError:
        remove_all(output_name)
        raise
    finally:
        remove_all(dvi_fn)
    for line in data.split('\n'):
        found = DVIPNG_REGEX.search(line)
        if found:
            return dict(zip(['depth', 'height', 'width'],
                map(float, found.groups())))
    raise ValueError("Could not parse dvi output: " + repr(data))

def create_svg(dvi_fn, output_name):
    """Create a SVG file from a given dvi file. The side effect is the SVG file
    being written to disk.
    :param dvi_fn       Dvi file name
    :param output_name  Output file name
    :param size         font size in pt
    :return dimensions for embedding into an HTML document
    :raises ValueError raised whenever dvipng output coudln't be parsed"""
    if not output_name:
        raise ValueError("Empty output_name")
    cmd = ['dvisvgm', '--exact', '--no-fonts', '-o', output_name,
            '--bbox=preview', dvi_fn]
    data = None
    try:
        data = proc_call(cmd, install_recommends='texlive-binaries')
    except subprocess.SubprocessError:
        remove_all(output_name)
        raise
    finally:
        remove_all(dvi_fn)
    pos = {}
    for line in data.split('\n'):
        if not pos:
            found = DVISVGM_DEPTH_REGEX.search(line)
            if found:
                # convert from pt to px (assuming 96 dpi)
                pos['depth'] = float(found.groups()[0]) * 1.3333333
        else:
            found = DVISVGM_SIZE_REGEX.search(line)
            if found:
                pos.update(dict(zip(['width', 'height'],
                                    # convert from pt to px (assuming 96 dpi)
                                    (float(v) * 1.3333333 for v in found.groups()))))
                return pos
    raise ValueError("Could not parse dvisvgm output: " + repr(data))