add UnicodeEngine (MultiEngineText and axis texters returning MultiEngineText), texte...
[PyX.git] / pyx / text.py
blobfc301b5278511508c68a419f3df8878602b8fca4
1 # Copyright (C) 2002-2013 Jörg Lehmann <joergl@users.sourceforge.net>
2 # Copyright (C) 2003-2011 Michael Schindler <m-schindler@users.sourceforge.net>
3 # Copyright (C) 2002-2013 André Wobst <wobsta@users.sourceforge.net>
5 # This file is part of PyX (http://pyx.sourceforge.net/).
7 # PyX is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 2 of the License, or
10 # (at your option) any later version.
12 # PyX is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with PyX; if not, write to the Free Software
19 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
21 import atexit, errno, functools, glob, inspect, io, itertools, logging, os
22 import queue, re, shutil, sys, tempfile, textwrap, threading
24 from pyx import config, unit, box, baseclasses, trafo, version, attr, style, path, canvas
25 from pyx import bbox as bboxmodule
26 from pyx.dvi import dvifile
28 logger = logging.getLogger("pyx")
31 def indent_text(text):
32 "Prepends two spaces to each line in text."
33 return "".join(" " + line for line in text.splitlines(True))
36 def remove_string(p, s):
37 """Removes a string from a string.
39 The function removes the first occurrence of a string in another string.
41 :param str p: string to be removed
42 :param str s: string to be searched
43 :returns: tuple of the result string and a success boolean (``True`` when
44 the string was removed)
45 :rtype: tuple of str and bool
47 Example:
48 >>> remove_string("XXX", "abcXXXdefXXXghi")
49 ('abcdefXXXghi', True)
51 """
52 r = s.replace(p, '', 1)
53 return r, r != s
56 def remove_pattern(p, s, ignore_nl=True):
57 r"""Removes a pattern from a string.
59 The function removes the first occurence of the pattern from a string. It
60 returns a tuple of the resulting string and the matching object (or
61 ``None``, if the pattern did not match).
63 :param re.regex p: pattern to be removed
64 :param str s: string to be searched
65 :param bool ignore_nl: When ``True``, newlines in the string are ignored
66 during the pattern search. The returned string will still contain all
67 newline characters outside of the matching part of the string, whereas
68 the returned matching object will not contain the newline characters
69 inside of the matching part of the string.
70 :returns: the result string and the match object or ``None`` if
71 search failed
72 :rtype: tuple of str and (re.match or None)
74 Example:
75 >>> r, m = remove_pattern(re.compile("XXX"), 'ab\ncXX\nXdefXX\nX')
76 >>> r
77 'ab\ncdefXX\nX'
78 >>> m.string[m.start():m.end()]
79 'XXX'
81 """
82 if ignore_nl:
83 r = s.replace('\n', '')
84 has_nl = r != s
85 else:
86 r = s
87 has_nl = False
88 m = p.search(r)
89 if m:
90 s_start = r_start = m.start()
91 s_end = r_end = m.end()
92 if has_nl:
93 j = 0
94 for c in s:
95 if c == '\n':
96 if j < r_end:
97 s_end += 1
98 if j <= r_start:
99 s_start += 1
100 else:
101 j += 1
102 return s[:s_start] + s[s_end:], m
103 return s, None
106 def index_all(c, s):
107 """Return list of positions of a character in a string.
109 Example:
110 >>> index_all("X", "abXcdXef")
111 [2, 5]
114 assert len(c) == 1
115 return [i for i, x in enumerate(s) if x == c]
118 def pairwise(i):
119 """Returns iterator over pairs of data from an iterable.
121 Example:
122 >>> list(pairwise([1, 2, 3]))
123 [(1, 2), (2, 3)]
126 a, b = itertools.tee(i)
127 next(b, None)
128 return zip(a, b)
131 def remove_nested_brackets(s, openbracket="(", closebracket=")", quote='"'):
132 """Remove nested brackets
134 Return a modified string with all nested brackets 1 removed, i.e. only
135 keep the first bracket nesting level. In case an opening bracket is
136 immediately followed by a quote, the quoted string is left untouched,
137 even if it contains brackets. The use-case for that are files in the
138 folder "Program Files (x86)".
140 If the bracket nesting level is broken (unbalanced), the unmodified
141 string is returned.
143 Example:
144 >>> remove_nested_brackets('aaa("bb()bb" cc(dd(ee))ff)ggg'*2)
145 'aaa("bb()bb" ccff)gggaaa("bb()bb" ccff)ggg'
148 openpos = index_all(openbracket, s)
149 closepos = index_all(closebracket, s)
150 if quote is not None:
151 quotepos = index_all(quote, s)
152 for openquote, closequote in pairwise(quotepos):
153 if openquote-1 in openpos:
154 # ignore brackets in quoted string
155 openpos = [pos for pos in openpos
156 if not (openquote < pos < closequote)]
157 closepos = [pos for pos in closepos
158 if not (openquote < pos < closequote)]
159 if len(openpos) != len(closepos):
160 # unbalanced brackets
161 return s
163 # keep the original string in case we need to return due to broken nesting levels
164 r = s
166 level = 0
167 # Iterate over the bracket positions from the end.
168 # We go reversely to be able to immediately remove nested bracket levels
169 # without influencing bracket positions yet to come in the loop.
170 for pos, leveldelta in sorted(itertools.chain(zip(openpos, itertools.repeat(-1)),
171 zip(closepos, itertools.repeat(1))),
172 reverse=True):
173 # the current bracket nesting level
174 level += leveldelta
175 if level < 0:
176 # unbalanced brackets
177 return s
178 if leveldelta == 1 and level == 2:
179 # a closing bracket to cut after
180 endpos = pos+1
181 if leveldelta == -1 and level == 1:
182 # an opening bracket to cut at -> remove
183 r = r[:pos] + r[endpos:]
184 return r
187 class TexResultError(ValueError):
188 "Error raised by :class:`texmessage` parsers."
189 pass
192 class texmessage:
193 """Collection of TeX output parsers.
195 This class is not meant to be instanciated. Instead, it serves as a
196 namespace for TeX output parsers, which are functions receiving a TeX
197 output and returning parsed output.
199 In addition, this class also contains some generator functions (namely
200 :attr:`texmessage.no_file` and :attr:`texmessage.pattern`), which return a
201 function according to the given parameters. They are used to generate some
202 of the parsers in this class and can be used to create others as well.
205 start_pattern = re.compile(r"This is [-0-9a-zA-Z\s_]*TeX")
207 @staticmethod
208 def start(msg):
209 r"""Validate TeX/LaTeX startup message including scrollmode test.
211 Example:
212 >>> texmessage.start(r'''
213 ... This is e-TeX (version)
214 ... *! Undefined control sequence.
215 ... <*> \raiseerror
216 ... %
217 ... ''')
221 # check for "This is e-TeX" etc.
222 if not texmessage.start_pattern.search(msg):
223 raise TexResultError("TeX startup failed")
225 # check for \raiseerror -- just to be sure that communication works
226 new = msg.split("*! Undefined control sequence.\n<*> \\raiseerror\n %\n", 1)[-1]
227 if msg == new:
228 raise TexResultError("TeX scrollmode check failed")
229 return new
231 @staticmethod
232 def no_file(fileending, qualname=None):
233 "Generator function to ignore the missing file message for fileending."
234 def check(msg):
235 "Ignore the missing {} file message."
236 return msg.replace("No file texput.%s." % fileending, "").replace("No file %s%stexput.%s." % (os.curdir, os.sep, fileending), "")
237 check.__doc__ = check.__doc__.format(fileending)
238 if qualname is not None:
239 check.__qualname__ = qualname
240 return check
242 no_aux = staticmethod(no_file.__func__("aux", "texmessage.no_aux"))
243 no_nav = staticmethod(no_file.__func__("nav", "texmessage.no_nav"))
245 aux_pattern = re.compile(r'\(([^()]+\.aux|"[^"]+\.aux")\)')
246 log_pattern = re.compile(r"Transcript written on .*texput\.log\.", re.DOTALL)
248 @staticmethod
249 def end(msg):
250 "Validate TeX shutdown message."
251 msg = re.sub(texmessage.aux_pattern, "", msg).replace("(see the transcript file for additional information)", "")
253 # check for "Transcript written on ...log."
254 msg, m = remove_pattern(texmessage.log_pattern, msg)
255 if not m:
256 raise TexResultError("TeX logfile message expected")
257 return msg
259 quoted_file_pattern = re.compile(r'\("(?P<filename>[^"]+)".*?\)')
260 file_pattern = re.compile(r'\((?P<filename>[^"][^ )]*).*?\)', re.DOTALL)
262 @staticmethod
263 def load(msg):
264 """Ignore file loading messages.
266 Removes text starting with a round bracket followed by a filename
267 ignoring all further text until the corresponding closing bracket.
268 Quotes and/or line breaks in the filename are handled as needed for TeX
269 output.
271 Without quoting the filename, the necessary removal of line breaks is
272 not well defined and the different possibilities are tested to check
273 whether one solution is ok. The last of the examples below checks this
274 behavior.
276 Examples:
277 >>> texmessage.load(r'''other (text.py) things''')
278 'other things'
279 >>> texmessage.load(r'''other ("text.py") things''')
280 'other things'
281 >>> texmessage.load(r'''other ("tex
282 ... t.py" further (ignored)
283 ... text) things''')
284 'other things'
285 >>> texmessage.load(r'''other (t
286 ... ext
287 ... .py
288 ... fur
289 ... ther (ignored) text) things''')
290 'other things'
293 r = remove_nested_brackets(msg)
294 r, m = remove_pattern(texmessage.quoted_file_pattern, r)
295 while m:
296 if not os.path.isfile(config.get("text", "chroot", "") + m.group("filename")):
297 return msg
298 r, m = remove_pattern(texmessage.quoted_file_pattern, r)
299 r, m = remove_pattern(texmessage.file_pattern, r, ignore_nl=False)
300 while m:
301 for filename in itertools.accumulate(m.group("filename").split("\n")):
302 if os.path.isfile(config.get("text", "chroot", "") + filename):
303 break
304 else:
305 return msg
306 r, m = remove_pattern(texmessage.file_pattern, r, ignore_nl=False)
307 return r
309 quoted_def_pattern = re.compile(r'\("(?P<filename>[^"]+\.(fd|def))"\)')
310 def_pattern = re.compile(r'\((?P<filename>[^"][^ )]*\.(fd|def))\)')
312 @staticmethod
313 def load_def(msg):
314 "Ignore font definition (``*.fd`` and ``*.def``) loading messages."
315 r = msg
316 for p in [texmessage.quoted_def_pattern, texmessage.def_pattern]:
317 r, m = remove_pattern(p, r)
318 while m:
319 if not os.path.isfile(config.get("text", "chroot", "") + m.group("filename")):
320 return msg
321 r, m = remove_pattern(p, r)
322 return r
324 quoted_graphics_pattern = re.compile(r'<"(?P<filename>[^"]+\.eps)">')
325 graphics_pattern = re.compile(r'<(?P<filename>[^"][^>]*\.eps)>')
327 @staticmethod
328 def load_graphics(msg):
329 "Ignore graphics file (``*.eps``) loading messages."
330 r = msg
331 for p in [texmessage.quoted_graphics_pattern, texmessage.graphics_pattern]:
332 r, m = remove_pattern(p, r)
333 while m:
334 if not os.path.isfile(config.get("text", "chroot", "") + m.group("filename")):
335 return msg
336 r, m = remove_pattern(texmessage.quoted_file_pattern, r)
337 return r
339 @staticmethod
340 def ignore(msg):
341 """Ignore all messages.
343 Should be used as a last resort only. You should write a proper TeX
344 output parser function for the output you observe.
347 return ""
349 @staticmethod
350 def warn(msg):
351 """Warn about all messages.
353 Similar to :attr:`ignore`, but writing a warning to the logger about
354 the TeX output. This is considered to be better when you need to get it
355 working quickly as you will still be prompted about the unresolved
356 output, while the processing continues.
359 if msg:
360 logger.warning("ignoring TeX warnings:\n%s" % indent_text(msg.rstrip()))
361 return ""
363 @staticmethod
364 def pattern(p, warning, qualname=None):
365 "Warn by regular expression pattern matching."
366 def check(msg):
367 "Warn about {}."
368 msg, m = remove_pattern(p, msg, ignore_nl=False)
369 while m:
370 logger.warning("ignoring %s:\n%s" % (warning, m.string[m.start(): m.end()].rstrip()))
371 msg, m = remove_pattern(p, msg, ignore_nl=False)
372 return msg
373 check.__doc__ = check.__doc__.format(warning)
374 if qualname is not None:
375 check.__qualname__ = qualname
376 return check
378 box_warning = staticmethod(pattern.__func__(re.compile(r"^(Overfull|Underfull) \\[hv]box.*$(\n^..*$)*\n^$\n", re.MULTILINE),
379 "overfull/underfull box", qualname="texmessage.box_warning"))
380 font_warning = staticmethod(pattern.__func__(re.compile(r"^LaTeX Font Warning: .*$(\n^\(Font\).*$)*", re.MULTILINE),
381 "font substitutions of NFSS", qualname="texmessage.font_warning"))
382 package_warning = staticmethod(pattern.__func__(re.compile(r"^package\s+(?P<packagename>\S+)\s+warning\s*:[^\n]+(?:\n\(?(?P=packagename)\)?[^\n]*)*", re.MULTILINE | re.IGNORECASE),
383 "generic package messages", qualname="texmessage.package_warning"))
384 rerun_warning = staticmethod(pattern.__func__(re.compile(r"^(LaTeX Warning: Label\(s\) may have changed\. Rerun to get cross-references right\s*\.)$", re.MULTILINE),
385 "rerun required message", qualname="texmessage.rerun_warning"))
386 nobbl_warning = staticmethod(pattern.__func__(re.compile(r"^[\s\*]*(No file .*\.bbl.)\s*", re.MULTILINE),
387 "no-bbl message", qualname="texmessage.nobbl_warning"))
390 ###############################################################################
391 # textattrs
392 ###############################################################################
394 _textattrspreamble = ""
396 class textattr:
397 "a textattr defines a apply method, which modifies a (La)TeX expression"
399 class _localattr: pass
401 _textattrspreamble += r"""\gdef\PyXFlushHAlign{0}%
402 \def\PyXragged{%
403 \leftskip=0pt plus \PyXFlushHAlign fil%
404 \rightskip=0pt plus 1fil%
405 \advance\rightskip0pt plus -\PyXFlushHAlign fil%
406 \parfillskip=0pt%
407 \pretolerance=9999%
408 \tolerance=9999%
409 \parindent=0pt%
410 \hyphenpenalty=9999%
411 \exhyphenpenalty=9999}%
414 class boxhalign(attr.exclusiveattr, textattr, _localattr):
416 def __init__(self, aboxhalign):
417 self.boxhalign = aboxhalign
418 attr.exclusiveattr.__init__(self, boxhalign)
420 def apply(self, expr):
421 return r"\gdef\PyXBoxHAlign{%.5f}%s" % (self.boxhalign, expr)
423 boxhalign.left = boxhalign(0)
424 boxhalign.center = boxhalign(0.5)
425 boxhalign.right = boxhalign(1)
426 # boxhalign.clear = attr.clearclass(boxhalign) # we can't defined a clearclass for boxhalign since it can't clear a halign's boxhalign
429 class flushhalign(attr.exclusiveattr, textattr, _localattr):
431 def __init__(self, aflushhalign):
432 self.flushhalign = aflushhalign
433 attr.exclusiveattr.__init__(self, flushhalign)
435 def apply(self, expr):
436 return r"\gdef\PyXFlushHAlign{%.5f}\PyXragged{}%s" % (self.flushhalign, expr)
438 flushhalign.left = flushhalign(0)
439 flushhalign.center = flushhalign(0.5)
440 flushhalign.right = flushhalign(1)
441 # flushhalign.clear = attr.clearclass(flushhalign) # we can't defined a clearclass for flushhalign since it couldn't clear a halign's flushhalign
444 class halign(boxhalign, flushhalign, _localattr):
446 def __init__(self, aboxhalign, aflushhalign):
447 self.boxhalign = aboxhalign
448 self.flushhalign = aflushhalign
449 attr.exclusiveattr.__init__(self, halign)
451 def apply(self, expr):
452 return r"\gdef\PyXBoxHAlign{%.5f}\gdef\PyXFlushHAlign{%.5f}\PyXragged{}%s" % (self.boxhalign, self.flushhalign, expr)
454 def apply_trafo(self, textbox):
455 return textbox.transform(trafo.translate_pt(-self.boxhalign*textbox.bbox().width_pt(), 0), keep_anchor=True)
457 halign.left = halign(0, 0)
458 halign.center = halign(0.5, 0.5)
459 halign.right = halign(1, 1)
460 halign.clear = attr.clearclass(halign)
461 halign.boxleft = boxhalign.left
462 halign.boxcenter = boxhalign.center
463 halign.boxright = boxhalign.right
464 halign.flushleft = halign.raggedright = flushhalign.left
465 halign.flushcenter = halign.raggedcenter = flushhalign.center
466 halign.flushright = halign.raggedleft = flushhalign.right
469 class _mathmode(attr.exclusiveattr, textattr, _localattr):
470 "math mode"
472 def __init__(self):
473 attr.exclusiveattr.__init__(self, _mathmode)
475 def apply(self, expr):
476 return r"$\displaystyle{%s}$" % expr
478 def apply_trafo(self, textbox):
479 pass
481 mathmode = _mathmode()
482 clearmathmode = attr.clearclass(_mathmode)
485 class _phantom(attr.attr, textattr, _localattr):
486 "phantom text"
488 def apply(self, expr):
489 return r"\phantom{%s}" % expr
491 phantom = _phantom()
492 clearphantom = attr.clearclass(_phantom)
495 _textattrspreamble += "\\newbox\\PyXBoxVBox%\n\\newdimen\\PyXDimenVBox%\n"
497 class parbox_pt(attr.sortbeforeexclusiveattr, textattr):
499 top = 1
500 middle = 2
501 bottom = 3
503 def __init__(self, width, baseline=top):
504 self.width = width * 72.27 / (unit.scale["x"] * 72)
505 self.baseline = baseline
506 attr.sortbeforeexclusiveattr.__init__(self, parbox_pt, [_localattr])
508 def apply(self, expr):
509 if self.baseline == self.top:
510 return r"\linewidth=%.5ftruept\vtop{\hsize=\linewidth\textwidth=\linewidth{}%s}" % (self.width, expr)
511 elif self.baseline == self.middle:
512 return r"\linewidth=%.5ftruept\setbox\PyXBoxVBox=\hbox{{\vtop{\hsize=\linewidth\textwidth=\linewidth{}%s}}}\PyXDimenVBox=0.5\dp\PyXBoxVBox\setbox\PyXBoxVBox=\hbox{{\vbox{\hsize=\linewidth\textwidth=\linewidth{}%s}}}\advance\PyXDimenVBox by -0.5\dp\PyXBoxVBox\lower\PyXDimenVBox\box\PyXBoxVBox" % (self.width, expr, expr)
513 elif self.baseline == self.bottom:
514 return r"\linewidth=%.5ftruept\vbox{\hsize=\linewidth\textwidth=\linewidth{}%s}" % (self.width, expr)
515 else:
516 ValueError("invalid baseline argument")
518 parbox_pt.clear = attr.clearclass(parbox_pt)
520 class parbox(parbox_pt):
522 def __init__(self, width, **kwargs):
523 parbox_pt.__init__(self, unit.topt(width), **kwargs)
525 parbox.clear = parbox_pt.clear
528 _textattrspreamble += "\\newbox\\PyXBoxVAlign%\n\\newdimen\\PyXDimenVAlign%\n"
530 class valign(attr.sortbeforeexclusiveattr, textattr):
532 def __init__(self, avalign):
533 self.valign = avalign
534 attr.sortbeforeexclusiveattr.__init__(self, valign, [parbox_pt, _localattr])
536 def apply(self, expr):
537 return r"\setbox\PyXBoxVAlign=\hbox{{%s}}\PyXDimenVAlign=%.5f\ht\PyXBoxVAlign\advance\PyXDimenVAlign by -%.5f\dp\PyXBoxVAlign\lower\PyXDimenVAlign\box\PyXBoxVAlign" % (expr, 1-self.valign, self.valign)
539 valign.top = valign(0)
540 valign.middle = valign(0.5)
541 valign.bottom = valign(1)
542 valign.clear = valign.baseline = attr.clearclass(valign)
545 _textattrspreamble += "\\newdimen\\PyXDimenVShift%\n"
547 class _vshift(attr.sortbeforeattr, textattr):
549 def __init__(self):
550 attr.sortbeforeattr.__init__(self, [valign, parbox_pt, _localattr])
552 def apply(self, expr):
553 return r"%s\setbox0\hbox{{%s}}\lower\PyXDimenVShift\box0" % (self.setheightexpr(), expr)
555 class vshift(_vshift):
556 "vertical down shift by a fraction of a character height"
558 def __init__(self, lowerratio, heightstr="0"):
559 _vshift.__init__(self)
560 self.lowerratio = lowerratio
561 self.heightstr = heightstr
563 def setheightexpr(self):
564 return r"\setbox0\hbox{{%s}}\PyXDimenVShift=%.5f\ht0" % (self.heightstr, self.lowerratio)
566 class _vshiftmathaxis(_vshift):
567 "vertical down shift by the height of the math axis"
569 def setheightexpr(self):
570 return r"\setbox0\hbox{$\vcenter{\vrule width0pt}$}\PyXDimenVShift=\ht0"
572 def apply_trafo(self, textbox):
573 return textbox.transform(trafo.translate_pt(0, -textbox.mathaxis_pt*unit.scale["x"]), keep_anchor=True)
576 vshift.bottomzero = vshift(0)
577 vshift.middlezero = vshift(0.5)
578 vshift.topzero = vshift(1)
579 vshift.mathaxis = _vshiftmathaxis()
580 vshift.clear = attr.clearclass(_vshift)
583 defaultsizelist = ["normalsize", "large", "Large", "LARGE", "huge", "Huge",
584 None, "tiny", "scriptsize", "footnotesize", "small"]
586 class size(attr.sortbeforeattr, textattr):
587 "font size"
589 def __init__(self, sizeindex=None, sizename=None, sizelist=defaultsizelist):
590 if (sizeindex is None and sizename is None) or (sizeindex is not None and sizename is not None):
591 raise ValueError("either specify sizeindex or sizename")
592 attr.sortbeforeattr.__init__(self, [_mathmode, _vshift])
593 if sizeindex is not None:
594 if sizeindex >= 0 and sizeindex < sizelist.index(None):
595 self.size = sizelist[sizeindex]
596 elif sizeindex < 0 and sizeindex + len(sizelist) > sizelist.index(None):
597 self.size = sizelist[sizeindex]
598 else:
599 raise IndexError("index out of sizelist range")
600 else:
601 self.size = sizename
603 def apply(self, expr):
604 return r"\%s{}%s" % (self.size, expr)
606 size.tiny = size(-4)
607 size.scriptsize = size.script = size(-3)
608 size.footnotesize = size.footnote = size(-2)
609 size.small = size(-1)
610 size.normalsize = size.normal = size(0)
611 size.large = size(1)
612 size.Large = size(2)
613 size.LARGE = size(3)
614 size.huge = size(4)
615 size.Huge = size(5)
616 size.clear = attr.clearclass(size)
619 ###############################################################################
620 # texrunner
621 ###############################################################################
624 class MonitorOutput(threading.Thread):
626 def __init__(self, name, output):
627 """Deadlock-safe output stream reader and monitor.
629 An instance of this class creates a thread to continously read lines
630 from a stream. By that a deadlock due to a full pipe is prevented. In
631 addition, the stream content can be monitored for containing a certain
632 string (see :meth:`expect` and :meth:`wait`) and return all the
633 collected output (see :meth:`read`).
635 :param string name: name to be used while logging in :meth:`wait` and
636 :meth:`done`
637 :param file output: output stream
640 self.output = output
641 self._expect = queue.Queue(1)
642 self._received = threading.Event()
643 self._output = queue.Queue()
644 threading.Thread.__init__(self, name=name)
645 self.daemon = True
646 self.start()
648 def expect(self, s):
649 """Expect a string on a **single** line in the output.
651 This method must be called **before** the output occurs, i.e. before
652 the input is written to the TeX/LaTeX process.
654 :param s: expected string or ``None`` if output is expected to become
655 empty
656 :type s: str or None
659 self._expect.put_nowait(s)
661 def read(self):
662 """Read all output collected since its previous call.
664 The output reading should be synchronized by the :meth:`expect`
665 and :meth:`wait` methods.
667 :returns: collected output from the stream
668 :rtype: str
671 l = []
672 try:
673 while True:
674 l.append(self._output.get_nowait())
675 except queue.Empty:
676 pass
677 return "".join(l).replace("\r\n", "\n").replace("\r", "\n")
679 def _wait(self, waiter, checker):
680 """Helper method to implement :meth:`wait` and :meth:`done`.
682 Waits for an event using the *waiter* and *checker* functions while
683 providing user feedback to the ``pyx``-logger using the warning level
684 according to the ``wait`` and ``showwait`` from the ``text`` section of
685 the pyx :mod:`config`.
687 :param function waiter: callback to wait for (the function gets called
688 with a timeout parameter)
689 :param function checker: callback returing ``True`` if
690 waiting was successful
691 :returns: ``True`` when wait was successful
692 :rtype: bool
695 wait = config.getint("text", "wait", 60)
696 showwait = config.getint("text", "showwait", 5)
697 if showwait:
698 waited = 0
699 hasevent = False
700 while waited < wait and not hasevent:
701 if wait - waited > showwait:
702 waiter(showwait)
703 waited += showwait
704 else:
705 waiter(wait - waited)
706 waited += wait - waited
707 hasevent = checker()
708 if not hasevent:
709 if waited < wait:
710 logger.warning("Still waiting for {} "
711 "after {} (of {}) seconds..."
712 .format(self.name, waited, wait))
713 else:
714 logger.warning("The timeout of {} seconds expired "
715 "and {} did not respond."
716 .format(waited, self.name))
717 return hasevent
718 else:
719 waiter(wait)
720 return checker()
722 def wait(self):
723 """Wait for the expected output to happen.
725 Waits either until a line containing the string set by the previous
726 :meth:`expect` call is found, or a timeout occurs.
728 :returns: ``True`` when the expected string was found
729 :rtype: bool
732 r = self._wait(self._received.wait, self._received.isSet)
733 if r:
734 self._received.clear()
735 return r
737 def done(self):
738 """Waits until the output becomes empty.
740 Waits either until the output becomes empty, or a timeout occurs.
741 The generated output can still be catched by :meth:`read` after
742 :meth:`done` was successful.
744 In the proper workflow :meth:`expect` should be called with ``None``
745 before the output completes, as otherwise a ``ValueError`` is raised
746 in the :meth:`run`.
748 :returns: ``True`` when the output has become empty
749 :rtype: bool
752 return self._wait(self.join, lambda self=self: not self.is_alive())
754 def _readline(self):
755 """Read a line from the output.
757 To be used **inside** the thread routine only.
759 :returns: one line of the output as a string
760 :rtype: str
763 while True:
764 try:
765 return self.output.readline()
766 except IOError as e:
767 if e.errno != errno.EINTR:
768 raise
770 def run(self):
771 """Thread routine.
773 **Not** to be called from outside.
775 :raises ValueError: output becomes empty while some string is expected
778 expect = None
779 while True:
780 line = self._readline()
781 if expect is None:
782 try:
783 expect = self._expect.get_nowait()
784 except queue.Empty:
785 pass
786 if not line:
787 break
788 self._output.put(line)
789 if expect is not None:
790 found = line.find(expect)
791 if found != -1:
792 self._received.set()
793 expect = None
794 self.output.close()
795 if expect is not None:
796 raise ValueError("{} finished unexpectedly".format(self.name))
799 class textbox_pt(box.rect, baseclasses.canvasitem): pass
802 class textextbox_pt(textbox_pt):
804 def __init__(self, x_pt, y_pt, left_pt, right_pt, height_pt, depth_pt, do_finish, fontmap, singlecharmode, fillstyles):
805 """Text output.
807 An instance of this class contains the text output generated by PyX. It
808 is a :class:`baseclasses.canvasitem` and thus can be inserted into a
809 canvas.
811 .. A text has a center (x_pt, y_pt) as well as extents in x-direction
812 .. (left_pt and right_pt) and y-direction (hight_pt and depth_pt). The
813 .. textbox positions the output accordingly and scales it by the
814 .. x-scale from the :mod:`unit`.
816 .. :param float x_pt: x coordinate of the center in pts
817 .. :param float y_pt: y coordinate of the center in pts
818 .. :param float left_pt: unscaled left extent in pts
819 .. :param float right_pt: unscaled right extent in pts
820 .. :param float height_pt: unscaled height in pts
821 .. :param float depth_pt: unscaled depth in pts
822 .. :param function do_finish: callable to execute :meth:`readdvipage`
823 .. :param fontmap: force a fontmap to be used (instead of the default
824 .. depending on the output format)
825 .. :type fontmap: None or fontmap
826 .. :param bool singlecharmode: position each character separately
827 .. :param fillstyles: fill styles to be applied
828 .. :type fillstyles: list of fillstyles
831 self.left = left_pt*unit.x_pt #: left extent of the text (PyX length)
832 self.right = right_pt*unit.x_pt #: right extent of the text (PyX length)
833 self.width = self.left + self.right #: width of the text (PyX length)
834 self.height = height_pt*unit.x_pt #: height of the text (PyX length)
835 self.depth = depth_pt*unit.x_pt #: height of the text (PyX length)
837 self.do_finish = do_finish
838 self.fontmap = fontmap
839 self.singlecharmode = singlecharmode
840 self.fillstyles = fillstyles
842 self.texttrafo = trafo.scale(unit.scale["x"]).translated_pt(x_pt, y_pt)
843 box.rect_pt.__init__(self, x_pt - left_pt*unit.scale["x"], y_pt - depth_pt*unit.scale["x"],
844 (left_pt + right_pt)*unit.scale["x"],
845 (depth_pt + height_pt)*unit.scale["x"],
846 abscenter_pt = (left_pt*unit.scale["x"], depth_pt*unit.scale["x"]))
848 self._dvicanvas = None
850 def transform(self, *trafos, keep_anchor=False):
851 box.rect.transform(self, *trafos, keep_anchor=keep_anchor)
852 for trafo in trafos:
853 self.texttrafo = trafo * self.texttrafo
854 if self._dvicanvas is not None:
855 for trafo in trafos:
856 self._dvicanvas.trafo = trafo * self._dvicanvas.trafo
858 def readdvipage(self, dvifile, page):
859 self._dvicanvas = dvifile.readpage([ord("P"), ord("y"), ord("X"), page, 0, 0, 0, 0, 0, 0],
860 fontmap=self.fontmap, singlecharmode=self.singlecharmode, attrs=[self.texttrafo] + self.fillstyles)
862 @property
863 def dvicanvas(self):
864 if self._dvicanvas is None:
865 self.do_finish()
866 return self._dvicanvas
868 def marker(self, name):
869 """Return the position of a marker.
871 :param str name: name of the marker
872 :returns: position of the marker
873 :rtype: tuple of two PyX lengths
875 This method returns the position of the marker of the given name
876 within, previously defined by the ``\\PyXMarker{name}`` macro in the
877 typeset text. Please do not use the ``@`` character within your name to
878 prevent name clashes with PyX internal uses (although we don’t the
879 marker feature internally right now).
881 Similar to generating actual output, the marker function accesses the
882 DVI output, stopping. The :ref:`texipc` feature will allow for this access
883 without stopping the TeX interpreter.
886 return self.texttrafo.apply(*self.dvicanvas.markers[name])
888 def textpath(self):
889 textpath = path.path()
890 for item in self.dvicanvas.items:
891 textpath += item.textpath()
892 return textpath.transformed(self.texttrafo)
894 def processPS(self, file, writer, context, registry, bbox):
895 abbox = bboxmodule.empty()
896 self.dvicanvas.processPS(file, writer, context, registry, abbox)
897 bbox += box.rect.bbox(self)
899 def processPDF(self, file, writer, context, registry, bbox):
900 abbox = bboxmodule.empty()
901 self.dvicanvas.processPDF(file, writer, context, registry, abbox)
902 bbox += box.rect.bbox(self)
904 def processSVG(self, xml, writer, context, registry, bbox):
905 abbox = bboxmodule.empty()
906 self.dvicanvas.processSVG(xml, writer, context, registry, abbox)
907 bbox += box.rect.bbox(self)
910 class _marker:
911 pass
914 class errordetail:
915 "Constants defining the verbosity of the :exc:`TexResultError`."
916 none = 0 #: Without any input and output.
917 default = 1 #: Input and parsed output shortend to 5 lines.
918 full = 2 #: Full input and unparsed as well as parsed output.
921 class Tee(object):
923 def __init__(self, *files):
924 """Apply write, flush, and close to each of the given files."""
925 self.files = files
927 def write(self, data):
928 for file in self.files:
929 file.write(data)
931 def flush(self):
932 for file in self.files:
933 file.flush()
935 def close(self):
936 for file in self.files:
937 file.close()
939 # The texrunner state represents the next (or current) execute state.
940 STATE_START, STATE_PREAMBLE, STATE_TYPESET, STATE_DONE = range(4)
941 PyXBoxPattern = re.compile(r"PyXBox:page=(?P<page>\d+),lt=(?P<lt>-?\d*((\d\.?)|(\.?\d))\d*)pt,rt=(?P<rt>-?\d*((\d\.?)|(\.?\d))\d*)pt,ht=(?P<ht>-?\d*((\d\.?)|(\.?\d))\d*)pt,dp=(?P<dp>-?\d*((\d\.?)|(\.?\d))\d*)pt:")
942 dvi_pattern = re.compile(r"Output written on .*texput\.dvi \((?P<page>\d+) pages?, \d+ bytes\)\.", re.DOTALL)
944 class TexDoneError(Exception):
945 pass
948 class SingleRunner:
950 #: default :class:`texmessage` parsers at interpreter startup
951 texmessages_start_default = [texmessage.start]
952 #: default :class:`texmessage` parsers at interpreter shutdown
953 texmessages_end_default = [texmessage.end, texmessage.font_warning, texmessage.rerun_warning, texmessage.nobbl_warning]
954 #: default :class:`texmessage` parsers for preamble output
955 texmessages_preamble_default = [texmessage.load]
956 #: default :class:`texmessage` parsers for typeset output
957 texmessages_run_default = [texmessage.font_warning, texmessage.box_warning, texmessage.package_warning,
958 texmessage.load_def, texmessage.load_graphics]
960 def __init__(self, cmd,
961 texenc="ascii",
962 usefiles=[],
963 texipc=config.getboolean("text", "texipc", 0),
964 copyinput=None,
965 dvitype=False,
966 errordetail=errordetail.default,
967 texmessages_start=[],
968 texmessages_end=[],
969 texmessages_preamble=[],
970 texmessages_run=[]):
971 """Base class for the TeX interface.
973 .. note:: This class cannot be used directly. It is the base class for
974 all texrunners and provides most of the implementation.
975 Still, to the end user the parameters except for *cmd*
976 are important, as they are preserved in derived classes
977 usually.
979 :param cmd: command and arguments to start the TeX interpreter
980 :type cmd: list of str
981 :param str texenc: encoding to use in the communication with the TeX
982 interpreter
983 :param usefiles: list of supplementary files to be copied to and from
984 the temporary working directory (see :ref:`debug` for usage
985 details)
986 :type usefiles: list of str
987 :param bool texipc: :ref:`texipc` flag.
988 :param copyinput: filename or file to be used to store a copy of all
989 the input passed to the TeX interpreter
990 :type copyinput: None or str or file
991 :param bool dvitype: flag to turn on dvitype-like output
992 :param errordetail: verbosity of the :exc:`TexResultError`
993 :type errordetail: :class:`errordetail`
994 :param texmessages_start: additional message parsers at interpreter
995 startup
996 :type texmessages_start: list of :class:`texmessage` parsers
997 :param texmessages_end: additional message parsers at interpreter
998 shutdown
999 :type texmessages_end: list of :class:`texmessage` parsers
1000 :param texmessages_preamble: additional message parsers for preamble
1001 output
1002 :type texmessages_preamble: list of :class:`texmessage` parsers
1003 :param texmessages_run: additional message parsers for typset output
1004 :type texmessages_run: list of :class:`texmessage` parsers
1007 self.cmd = cmd
1008 self.texenc = texenc
1009 self.usefiles = usefiles
1010 self.texipc = texipc
1011 self.copyinput = copyinput
1012 self.dvitype = dvitype
1013 self.errordetail = errordetail
1014 self.texmessages_start = texmessages_start
1015 self.texmessages_end = texmessages_end
1016 self.texmessages_preamble = texmessages_preamble
1017 self.texmessages_run = texmessages_run
1019 self.state = STATE_START
1020 self.executeid = 0
1021 self.page = 0
1023 self.needdvitextboxes = [] # when texipc-mode off
1024 self.dvifile = None
1026 def _cleanup(self):
1027 """Clean-up TeX interpreter and tmp directory.
1029 This funtion is hooked up in atexit to quit the TeX interpreter, to
1030 save the contents of usefiles, and to remove the temporary directory.
1033 try:
1034 if self.state > STATE_START:
1035 if self.state < STATE_DONE:
1036 self.do_finish(cleanup=False)
1037 if self.state < STATE_DONE: # cleanup while TeX is still running?
1038 self.texoutput.expect(None)
1039 self.force_done()
1040 for f, msg in [(self.texinput.close, "We tried to communicate to %s to quit itself, but this seem to have failed. Trying by terminate signal now ...".format(self.name)),
1041 (self.popen.terminate, "Failed, too. Trying by kill signal now ..."),
1042 (self.popen.kill, "We tried very hard to get rid of the %s process, but we ultimately failed (as far as we can tell). Sorry.".format(self.name))]:
1044 if self.texoutput.done():
1045 break
1046 logger.warning(msg)
1047 for usefile in self.usefiles:
1048 extpos = usefile.rfind(".")
1049 try:
1050 shutil.move(os.path.join(self.tmpdir, "texput" + usefile[extpos:]), usefile)
1051 except EnvironmentError:
1052 logger.warning("Could not save '{}'.".format(usefile))
1053 if os.path.isfile(usefile):
1054 try:
1055 os.unlink(usefile)
1056 except EnvironmentError:
1057 logger.warning("Failed to remove spurious file '{}'.".format(usefile))
1058 finally:
1059 shutil.rmtree(self.tmpdir, ignore_errors=True)
1061 def _execute(self, expr, texmessages, oldstate, newstate):
1062 """Execute TeX expression.
1064 :param str expr: expression to be passed to TeX
1065 :param texmessages: message parsers to analyse the textual output of
1067 :type texmessages: list of :class:`texmessage` parsers
1068 :param int oldstate: state of the TeX interpreter prior to the
1069 expression execution
1070 :param int newstate: state of the TeX interpreter after to the
1071 expression execution
1074 assert STATE_PREAMBLE <= oldstate <= STATE_TYPESET
1075 assert oldstate == self.state
1076 assert newstate >= oldstate
1077 if newstate == STATE_DONE:
1078 self.texoutput.expect(None)
1079 self.texinput.write(expr)
1080 else:
1082 # test to encode expr early to not pile up expected results
1083 # if the expression won't make it to the texinput at all
1084 # (which would otherwise harm a proper cleanup)
1085 expr.encode(self.texenc)
1087 if oldstate == newstate == STATE_TYPESET:
1088 self.page += 1
1089 expr = "\\ProcessPyXBox{%s%%\n}{%i}" % (expr, self.page)
1090 self.executeid += 1
1091 self.texoutput.expect("PyXInputMarker:executeid=%i:" % self.executeid)
1092 expr += "%%\n\\PyXInput{%i}%%\n" % self.executeid
1093 self.texinput.write(expr)
1094 self.texinput.flush()
1095 self.state = newstate
1096 if newstate == STATE_DONE:
1097 wait_ok = self.texoutput.done()
1098 else:
1099 wait_ok = self.texoutput.wait()
1100 try:
1101 parsed = unparsed = self.texoutput.read()
1102 if not wait_ok:
1103 raise TexResultError("TeX didn't respond as expected within the timeout period.")
1104 if newstate != STATE_DONE:
1105 parsed, m = remove_string("PyXInputMarker:executeid=%s:" % self.executeid, parsed)
1106 if not m:
1107 raise TexResultError("PyXInputMarker expected")
1108 if oldstate == newstate == STATE_TYPESET:
1109 parsed, m = remove_pattern(PyXBoxPattern, parsed, ignore_nl=False)
1110 if not m:
1111 raise TexResultError("PyXBox expected")
1112 if m.group("page") != str(self.page):
1113 raise TexResultError("Wrong page number in PyXBox")
1114 extent_pt = [float(x)*72/72.27 for x in m.group("lt", "rt", "ht", "dp")]
1115 parsed, m = remove_string("[80.121.88.%s]" % self.page, parsed)
1116 if not m:
1117 raise TexResultError("PyXPageOutMarker expected")
1118 else:
1119 # check for "Output written on ...dvi (1 page, 220 bytes)."
1120 if self.page:
1121 parsed, m = remove_pattern(dvi_pattern, parsed)
1122 if not m:
1123 raise TexResultError("TeX dvifile messages expected")
1124 if m.group("page") != str(self.page):
1125 raise TexResultError("wrong number of pages reported")
1126 else:
1127 parsed, m = remove_string("No pages of output.", parsed)
1128 if not m:
1129 raise TexResultError("no dvifile expected")
1131 for t in texmessages:
1132 parsed = t(parsed)
1133 if parsed.replace(r"(Please type a command or say `\end')", "").replace(" ", "").replace("*\n", "").replace("\n", ""):
1134 raise TexResultError("unhandled TeX response (might be an error)")
1135 except TexResultError as e:
1136 if self.errordetail > errordetail.none:
1137 def add(msg): e.args = (e.args[0] + msg,)
1138 add("\nThe expression passed to TeX was:\n{}".format(indent_text(expr.rstrip())))
1139 if self.errordetail == errordetail.full:
1140 add("\nThe return message from TeX was:\n{}".format(indent_text(unparsed.rstrip())))
1141 if self.errordetail == errordetail.default:
1142 if parsed.count('\n') > 6:
1143 parsed = "\n".join(parsed.split("\n")[:5] + ["(cut after 5 lines; use errordetail.full for all output)"])
1144 add("\nAfter parsing the return message from TeX, the following was left:\n{}".format(indent_text(parsed.rstrip())))
1145 raise e
1146 if oldstate == newstate == STATE_TYPESET:
1147 return extent_pt
1149 def do_start(self):
1150 """Setup environment and start TeX interpreter."""
1151 assert self.state == STATE_START
1152 self.state = STATE_PREAMBLE
1154 chroot = config.get("text", "chroot", "")
1155 if chroot:
1156 chroot_tmpdir = config.get("text", "tmpdir", "/tmp")
1157 chroot_tmpdir_rel = os.path.relpath(chroot_tmpdir, os.sep)
1158 base_tmpdir = os.path.join(chroot, chroot_tmpdir_rel)
1159 else:
1160 base_tmpdir = config.get("text", "tmpdir", None)
1161 self.tmpdir = tempfile.mkdtemp(prefix="pyx", dir=base_tmpdir)
1162 atexit.register(self._cleanup)
1163 for usefile in self.usefiles:
1164 extpos = usefile.rfind(".")
1165 try:
1166 os.rename(usefile, os.path.join(self.tmpdir, "texput" + usefile[extpos:]))
1167 except OSError:
1168 pass
1169 if chroot:
1170 tex_tmpdir = os.sep + os.path.relpath(self.tmpdir, chroot)
1171 else:
1172 tex_tmpdir = self.tmpdir
1173 cmd = self.cmd + ['--output-directory', tex_tmpdir]
1174 if self.texipc:
1175 cmd.append("--ipc")
1176 self.popen = config.Popen(cmd, stdin=config.PIPE, stdout=config.PIPE, stderr=config.STDOUT, bufsize=0)
1177 self.texinput = io.TextIOWrapper(self.popen.stdin, encoding=self.texenc)
1178 if self.copyinput:
1179 try:
1180 self.copyinput.write
1181 except AttributeError:
1182 self.texinput = Tee(open(self.copyinput, "w", encoding=self.texenc), self.texinput)
1183 else:
1184 self.texinput = Tee(self.copyinput, self.texinput)
1185 self.texoutput = MonitorOutput(self.name, io.TextIOWrapper(self.popen.stdout, encoding=self.texenc))
1186 self._execute("\\scrollmode\n\\raiseerror%\n" # switch to and check scrollmode
1187 "\\def\\PyX{P\\kern-.3em\\lower.5ex\hbox{Y}\kern-.18em X}%\n" # just the PyX Logo
1188 "\\gdef\\PyXBoxHAlign{0}%\n" # global PyXBoxHAlign (0.0-1.0) for the horizontal alignment, default to 0
1189 "\\newbox\\PyXBox%\n" # PyXBox will contain the output
1190 "\\newbox\\PyXBoxHAligned%\n" # PyXBox will contain the horizontal aligned output
1191 "\\newdimen\\PyXDimenHAlignLT%\n" # PyXDimenHAlignLT/RT will contain the left/right extent
1192 "\\newdimen\\PyXDimenHAlignRT%\n" +
1193 _textattrspreamble + # insert preambles for textattrs macros
1194 "\\long\\def\\ProcessPyXBox#1#2{%\n" # the ProcessPyXBox definition (#1 is expr, #2 is page number)
1195 "\\setbox\\PyXBox=\\hbox{{#1}}%\n" # push expression into PyXBox
1196 "\\PyXDimenHAlignLT=\\PyXBoxHAlign\\wd\\PyXBox%\n" # calculate the left/right extent
1197 "\\PyXDimenHAlignRT=\\wd\\PyXBox%\n"
1198 "\\advance\\PyXDimenHAlignRT by -\\PyXDimenHAlignLT%\n"
1199 "\\gdef\\PyXBoxHAlign{0}%\n" # reset the PyXBoxHAlign to the default 0
1200 "\\immediate\\write16{PyXBox:page=#2," # write page and extents of this box to stdout
1201 "lt=\\the\\PyXDimenHAlignLT,"
1202 "rt=\\the\\PyXDimenHAlignRT,"
1203 "ht=\\the\\ht\\PyXBox,"
1204 "dp=\\the\\dp\\PyXBox:}%\n"
1205 "\\setbox\\PyXBoxHAligned=\\hbox{\\kern-\\PyXDimenHAlignLT\\box\\PyXBox}%\n" # align horizontally
1206 "\\ht\\PyXBoxHAligned0pt%\n" # baseline alignment (hight to zero)
1207 "{\\count0=80\\count1=121\\count2=88\\count3=#2\\shipout\\box\\PyXBoxHAligned}}%\n" # shipout PyXBox to Page 80.121.88.<page number>
1208 "\\def\\PyXInput#1{\\immediate\\write16{PyXInputMarker:executeid=#1:}}%\n" # write PyXInputMarker to stdout
1209 "\\def\\PyXMarker#1{\\hskip0pt\\special{PyX:marker #1}}%", # write PyXMarker special into the dvi-file
1210 self.texmessages_start_default + self.texmessages_start, STATE_PREAMBLE, STATE_PREAMBLE)
1212 def do_preamble(self, expr, texmessages):
1213 """Ensure preamble mode and execute expr."""
1214 if self.state < STATE_PREAMBLE:
1215 self.do_start()
1216 self._execute(expr, texmessages, STATE_PREAMBLE, STATE_PREAMBLE)
1218 def do_typeset(self, expr, texmessages):
1219 """Ensure typeset mode and typeset expr."""
1220 if self.state < STATE_PREAMBLE:
1221 self.do_start()
1222 if self.state < STATE_TYPESET:
1223 self.go_typeset()
1224 return self._execute(expr, texmessages, STATE_TYPESET, STATE_TYPESET)
1226 def do_finish(self, cleanup=True):
1227 """Teardown TeX interpreter and cleanup environment.
1229 :param bool cleanup: use _cleanup regularly/explicitly (not via atexit)
1231 if self.state == STATE_DONE:
1232 return
1233 if self.state < STATE_TYPESET:
1234 self.go_typeset()
1235 self.go_finish()
1236 assert self.state == STATE_DONE
1237 self.texinput.close() # close the input queue and
1238 self.texoutput.done() # wait for finish of the output
1240 if self.needdvitextboxes:
1241 dvifilename = os.path.join(self.tmpdir, "texput.dvi")
1242 self.dvifile = dvifile.DVIfile(dvifilename, debug=self.dvitype)
1243 page = 1
1244 for box in self.needdvitextboxes:
1245 box.readdvipage(self.dvifile, page)
1246 page += 1
1247 if self.dvifile is not None and self.dvifile.readpage(None) is not None:
1248 raise ValueError("end of dvifile expected but further pages follow")
1249 if cleanup:
1250 atexit.unregister(self._cleanup)
1251 self._cleanup()
1253 def preamble(self, expr, texmessages=[]):
1254 """Execute a preamble.
1256 :param str expr: expression to be executed
1257 :param texmessages: additional message parsers
1258 :type texmessages: list of :class:`texmessage` parsers
1260 Preambles must not generate output, but are used to load files, perform
1261 settings, define macros, *etc*. In LaTeX mode, preambles are executed
1262 before ``\\begin{document}``. The method can be called multiple times,
1263 but only prior to :meth:`SingleRunner.text` and
1264 :meth:`SingleRunner.text_pt`.
1267 texmessages = self.texmessages_preamble_default + self.texmessages_preamble + texmessages
1268 self.do_preamble(expr, texmessages)
1270 def text_pt(self, x_pt, y_pt, expr, textattrs=[], texmessages=[], fontmap=None, singlecharmode=False):
1271 """Typeset text.
1273 :param float x_pt: x position in pts
1274 :param float y_pt: y position in pts
1275 :param expr: text to be typeset
1276 :type expr: str or :class:`MultiEngineText`
1277 :param textattrs: styles and attributes to be applied to the text
1278 :type textattrs: list of :class:`textattr, :class:`trafo.trafo_pt`,
1279 and :class:`style.fillstyle`
1280 :param texmessages: additional message parsers
1281 :type texmessages: list of :class:`texmessage` parsers
1282 :param fontmap: force a fontmap to be used (instead of the default
1283 depending on the output format)
1284 :type fontmap: None or fontmap
1285 :param bool singlecharmode: position each character separately
1286 :returns: text output insertable into a canvas.
1287 :rtype: :class:`textextbox_pt`
1288 :raises: :exc:`TexDoneError`: when the TeX interpreter has been
1289 terminated already.
1292 if self.state == STATE_DONE:
1293 raise TexDoneError("typesetting process was terminated already")
1294 textattrs = attr.mergeattrs(textattrs) # perform cleans
1295 attr.checkattrs(textattrs, [textattr, trafo.trafo_pt, style.fillstyle])
1296 trafos = attr.getattrs(textattrs, [trafo.trafo_pt])
1297 fillstyles = attr.getattrs(textattrs, [style.fillstyle])
1298 textattrs = attr.getattrs(textattrs, [textattr])
1299 if isinstance(expr, MultiEngineText):
1300 expr = expr.tex
1301 for ta in textattrs[::-1]:
1302 expr = ta.apply(expr)
1303 first = self.state < STATE_TYPESET
1304 left_pt, right_pt, height_pt, depth_pt = self.do_typeset(expr, self.texmessages_run_default + self.texmessages_run + texmessages)
1305 if self.texipc and first:
1306 self.dvifile = dvifile.DVIfile(os.path.join(self.tmpdir, "texput.dvi"), debug=self.dvitype)
1307 box = textextbox_pt(x_pt, y_pt, left_pt, right_pt, height_pt, depth_pt, self.do_finish, fontmap, singlecharmode, fillstyles)
1308 for t in trafos:
1309 box.reltransform(t) # TODO: should trafos really use reltransform???
1310 # this is quite different from what we do elsewhere!!!
1311 # see https://sourceforge.net/mailarchive/forum.php?thread_id=9137692&forum_id=23700
1312 if self.texipc:
1313 box.readdvipage(self.dvifile, self.page)
1314 else:
1315 self.needdvitextboxes.append(box)
1316 return box
1318 def text(self, x, y, *args, **kwargs):
1319 """Typeset text.
1321 This method is identical to :meth:`text_pt` with the only difference of
1322 using PyX lengths to position the output.
1324 :param x: x position
1325 :type x: PyX length
1326 :param y: y position
1327 :type y: PyX length
1330 return self.text_pt(unit.topt(x), unit.topt(y), *args, **kwargs)
1333 class SingleTexRunner(SingleRunner):
1335 def __init__(self, cmd=config.getlist("text", "tex", ["tex"]), lfs="10pt", **kwargs):
1336 """Plain TeX interface.
1338 This class adjusts the :class:`SingleRunner` to use plain TeX.
1340 :param cmd: command and arguments to start the TeX interpreter
1341 :type cmd: list of str
1342 :param lfs: resemble LaTeX font settings within plain TeX by loading a
1343 lfs-file
1344 :type lfs: str or None
1345 :param kwargs: additional arguments passed to :class:`SingleRunner`
1347 An lfs-file is a file defining a set of font commands like ``\\normalsize``
1348 by font selection commands in plain TeX. Several of those files
1349 resembling standard settings of LaTeX are distributed along with PyX in
1350 the ``pyx/data/lfs`` directory. This directory also contains a LaTeX
1351 file to create lfs files for different settings (LaTeX class, class
1352 options, and style files).
1355 super().__init__(cmd=cmd, **kwargs)
1356 self.lfs = lfs
1357 self.name = "TeX"
1359 def go_typeset(self):
1360 assert self.state == STATE_PREAMBLE
1361 self.state = STATE_TYPESET
1363 def go_finish(self):
1364 self._execute("\\end%\n", self.texmessages_end_default + self.texmessages_end, STATE_TYPESET, STATE_DONE)
1366 def force_done(self):
1367 self.texinput.write("\n\\end\n")
1369 def do_start(self):
1370 super().do_start()
1371 if self.lfs:
1372 if not self.lfs.endswith(".lfs"):
1373 self.lfs = "%s.lfs" % self.lfs
1374 with config.open(self.lfs, []) as lfsfile:
1375 lfsdef = lfsfile.read().decode("ascii")
1376 self._execute(lfsdef, [], STATE_PREAMBLE, STATE_PREAMBLE)
1377 self._execute("\\normalsize%\n", [], STATE_PREAMBLE, STATE_PREAMBLE)
1378 self._execute("\\newdimen\\linewidth\\newdimen\\textwidth%\n", [], STATE_PREAMBLE, STATE_PREAMBLE)
1381 class SingleLatexRunner(SingleRunner):
1383 #: default :class:`texmessage` parsers at LaTeX class loading
1384 texmessages_docclass_default = [texmessage.load]
1385 #: default :class:`texmessage` parsers at ``\begin{document}``
1386 texmessages_begindoc_default = [texmessage.load, texmessage.no_aux]
1388 def __init__(self, cmd=config.getlist("text", "latex", ["latex"]),
1389 docclass="article", docopt=None, pyxgraphics=True,
1390 texmessages_docclass=[], texmessages_begindoc=[], **kwargs):
1391 """LaTeX interface.
1393 This class adjusts the :class:`SingleRunner` to use LaTeX.
1395 :param cmd: command and arguments to start the TeX interpreter
1396 in LaTeX mode
1397 :type cmd: list of str
1398 :param str docclass: document class
1399 :param docopt: document loading options
1400 :type docopt: str or None
1401 :param bool pyxgraphics: activate graphics bundle support, see
1402 :ref:`pyxgraphics`
1403 :param texmessages_docclass: additional message parsers at LaTeX class
1404 loading
1405 :type texmessages_docclass: list of :class:`texmessage` parsers
1406 :param texmessages_begindoc: additional message parsers at
1407 ``\\begin{document}``
1408 :type texmessages_begindoc: list of :class:`texmessage` parsers
1409 :param kwargs: additional arguments passed to :class:`SingleRunner`
1412 super().__init__(cmd=cmd, **kwargs)
1413 self.docclass = docclass
1414 self.docopt = docopt
1415 self.pyxgraphics = pyxgraphics
1416 self.texmessages_docclass = texmessages_docclass
1417 self.texmessages_begindoc = texmessages_begindoc
1418 self.name = "LaTeX"
1420 def go_typeset(self):
1421 self._execute("\\begin{document}", self.texmessages_begindoc_default + self.texmessages_begindoc, STATE_PREAMBLE, STATE_TYPESET)
1423 def go_finish(self):
1424 self._execute("\\end{document}%\n", self.texmessages_end_default + self.texmessages_end, STATE_TYPESET, STATE_DONE)
1426 def force_done(self):
1427 self.texinput.write("\n\\catcode`\\@11\\relax\\@@end\n")
1429 def do_start(self):
1430 super().do_start()
1431 if self.pyxgraphics:
1432 with config.open("pyx.def", []) as source, open(os.path.join(self.tmpdir, "pyx.def"), "wb") as dest:
1433 dest.write(source.read())
1434 self._execute("\\makeatletter%\n"
1435 "\\let\\saveProcessOptions=\\ProcessOptions%\n"
1436 "\\def\\ProcessOptions{%\n"
1437 "\\def\\Gin@driver{" + self.tmpdir.replace(os.sep, "/") + "/pyx.def}%\n"
1438 "\\def\\c@lor@namefile{dvipsnam.def}%\n"
1439 "\\saveProcessOptions}%\n"
1440 "\\makeatother",
1441 [], STATE_PREAMBLE, STATE_PREAMBLE)
1442 if self.docopt is not None:
1443 self._execute("\\documentclass[%s]{%s}" % (self.docopt, self.docclass),
1444 self.texmessages_docclass_default + self.texmessages_docclass, STATE_PREAMBLE, STATE_PREAMBLE)
1445 else:
1446 self._execute("\\documentclass{%s}" % self.docclass,
1447 self.texmessages_docclass_default + self.texmessages_docclass, STATE_PREAMBLE, STATE_PREAMBLE)
1450 def reset_for_tex_done(f):
1451 @functools.wraps(f)
1452 def wrapped(self, *args, **kwargs):
1453 try:
1454 return f(self, *args, **kwargs)
1455 except TexDoneError:
1456 self.reset(reinit=True)
1457 return f(self, *args, **kwargs)
1458 return wrapped
1461 class MultiRunner:
1463 def __init__(self, cls, *args, **kwargs):
1464 """A restartable :class:`SingleRunner` class
1466 :param cls: the class being wrapped
1467 :type cls: :class:`SingleRunner` class
1468 :param list args: args at class instantiation
1469 :param dict kwargs: keyword args at at class instantiation
1472 self.cls = cls
1473 self.args = args
1474 self.kwargs = kwargs
1475 self.reset()
1477 def preamble(self, expr, texmessages=[]):
1478 "resembles :meth:`SingleRunner.preamble`"
1479 self.preambles.append((expr, texmessages))
1480 self.instance.preamble(expr, texmessages)
1482 @reset_for_tex_done
1483 def text_pt(self, *args, **kwargs):
1484 "resembles :meth:`SingleRunner.text_pt`"
1485 return self.instance.text_pt(*args, **kwargs)
1487 @reset_for_tex_done
1488 def text(self, *args, **kwargs):
1489 "resembles :meth:`SingleRunner.text`"
1490 return self.instance.text(*args, **kwargs)
1492 def reset(self, reinit=False):
1493 """Start a new :class:`SingleRunner` instance
1495 :param bool reinit: replay :meth:`preamble` calls on the new instance
1497 After executing this function further preamble calls are allowed,
1498 whereas once a text output has been created, :meth:`preamble` calls are
1499 forbidden.
1502 self.instance = self.cls(*self.args, **self.kwargs)
1503 if reinit:
1504 for expr, texmessages in self.preambles:
1505 self.instance.preamble(expr, texmessages)
1506 else:
1507 self.preambles = []
1510 class TexEngine(MultiRunner):
1512 def __init__(self, *args, **kwargs):
1513 """A restartable :class:`SingleTexRunner` class
1515 :param list args: args at class instantiation
1516 :param dict kwargs: keyword args at at class instantiation
1519 super().__init__(SingleTexRunner, *args, **kwargs)
1522 class LatexEngine(MultiRunner):
1524 def __init__(self, *args, **kwargs):
1525 """A restartable :class:`SingleLatexRunner` class
1527 :param list args: args at class instantiation
1528 :param dict kwargs: keyword args at at class instantiation
1531 super().__init__(SingleLatexRunner, *args, **kwargs)
1534 from pyx import deco
1535 from pyx.font import T1font
1536 from pyx.font.t1file import T1File
1537 from pyx.font.afmfile import AFMfile
1540 class MultiEngineText:
1542 def __init__(self, tex, unicode):
1543 self.tex = tex
1544 self.unicode = unicode
1547 class Text:
1549 def __init__(self, text, scale=1, shift=0):
1550 self.text = text
1551 self.scale = scale
1552 self.shift = shift
1555 class StackedText:
1557 def __init__(self, texts, frac=False, align=0):
1558 assert not frac or len(texts) == 2
1559 self.texts = texts
1560 self.frac = frac
1561 self.align = align
1564 class unicodetextbox_pt(textbox_pt):
1566 def __init__(self, x_pt, y_pt, texts, font, size, mathmode=False):
1567 self.font = font
1568 self.size = size
1569 self.canvas = canvas.canvas()
1570 self.texttrafo = trafo.scale(unit.scale["x"]).translated_pt(x_pt, y_pt)
1572 if isinstance(texts, (str, Text, StackedText)):
1573 texts = [texts]
1575 x_pt = 0
1576 for text in texts:
1577 if isinstance(text, str):
1578 text = Text(text)
1579 if isinstance(text, Text):
1580 if mathmode:
1581 text_fragments = text.text.split('-')
1582 else:
1583 text_fragments = [text.text]
1584 for i, text_fragment in enumerate(text_fragments):
1585 if i:
1586 self.canvas.fill(path.rect_pt(x_pt+0.5*(self.minuswidth_pt-self.minuslength_pt), self.mathaxis_pt-0.5*self.minusthickness_pt, self.minuslength_pt, self.minusthickness_pt))
1587 x_pt += self.minuswidth_pt
1588 if text_fragment:
1589 try:
1590 t = self.font.text_pt(x_pt, text.shift*self.size, text_fragment, text.scale*self.size)
1591 x_pt += t.bbox().width_pt()
1592 except:
1593 assert '·' in text_fragment
1594 t = self.font.text_pt(x_pt, text.shift*self.size, text_fragment.replace('·', 'x'), text.scale*self.size)
1595 x_pt += t.bbox().width_pt()
1596 self.canvas.insert(t)
1597 else:
1598 assert isinstance(text, StackedText)
1599 shift = self.mathaxis_pt if text.frac else 0
1600 ts = [self.font.text_pt(x_pt, text.shift*self.size+shift, text.text, text.scale*self.size)
1601 for text in text.texts]
1602 width_pt = max(t.bbox().width_pt() for t in ts)
1603 if text.frac:
1604 self.canvas.fill(path.rect_pt(x_pt, self.mathaxis_pt-0.5*self.minusthickness_pt, width_pt, self.minusthickness_pt))
1605 for t in ts:
1606 self.canvas.insert(t, [trafo.translate_pt(text.align*(width_pt-t.bbox().width_pt()), 0)] if text.align else [])
1607 x_pt += width_pt
1609 bbox = self.canvas.bbox()
1610 bbox.includepoint_pt(0, 0)
1611 bbox.includepoint_pt(x_pt, 0)
1612 box.rect_pt.__init__(self, bbox.llx_pt, bbox.lly_pt, bbox.urx_pt-bbox.llx_pt, bbox.ury_pt-bbox.lly_pt, abscenter_pt = (0, 0))
1613 box.rect.transform(self, self.texttrafo)
1615 def _extract_minus_properties(self):
1616 minus_path = self.font.text_pt(0, 0, '=', 1).textpath().normpath()
1617 minus_path.normsubpaths = [normsubpath for normsubpath in minus_path.normsubpaths if normsubpath]
1618 self.font.minusthickness_pt = max(normsubpath.bbox().height_pt() for normsubpath in minus_path.normsubpaths)
1619 self.font.halfminuswidth_pt, self.font.mathaxis_pt = minus_path.bbox().center_pt()
1620 self.font.minuslength_pt = minus_path.bbox().width_pt()
1622 @property
1623 def mathaxis_pt(self):
1624 if not hasattr(self.font, "mathaxis_pt"):
1625 self._extract_minus_properties()
1626 return self.font.mathaxis_pt*self.size
1628 @property
1629 def minuswidth_pt(self):
1630 if not hasattr(self.font, "halfminuswidth_pt"):
1631 self._extract_minus_properties()
1632 return 2*self.font.halfminuswidth_pt*self.size
1634 @property
1635 def minuslength_pt(self):
1636 if not hasattr(self.font, "minuslength_pt"):
1637 self._extract_minus_properties()
1638 return self.font.minuslength_pt*self.size
1640 @property
1641 def minusthickness_pt(self):
1642 if not hasattr(self.font, "minusthickness_pt"):
1643 self._extract_minus_properties()
1644 return self.font.minusthickness_pt*self.size
1646 def transform(self, *trafos, keep_anchor=False):
1647 box.rect.transform(self, *trafos, keep_anchor=keep_anchor)
1648 for trafo in trafos:
1649 self.texttrafo = trafo * self.texttrafo
1651 def bbox(self):
1652 return self.canvas.bbox().transformed(self.texttrafo)
1654 def textpath(self):
1655 r = path.path()
1656 for item in self.canvas.items:
1657 if isinstance(item, canvas.canvas):
1658 for subitem in item.items:
1659 r += subitem.textpath().transformed(item.trafo)
1660 elif isinstance(item, deco.decoratedpath):
1661 r += item.path
1662 else:
1663 r += item.textpath()
1664 return r.transformed(self.texttrafo)
1666 def processPS(self, file, writer, context, registry, bbox):
1667 c = canvas.canvas([self.texttrafo])
1668 c.insert(self.canvas)
1669 c.processPS(file, writer, context, registry, bbox)
1671 def processPDF(self, file, writer, context, registry, bbox):
1672 c = canvas.canvas([self.texttrafo])
1673 c.insert(self.canvas)
1674 c.processPDF(file, writer, context, registry, bbox)
1676 def processSVG(self, xml, writer, context, registry, bbox):
1677 c = canvas.canvas([self.texttrafo])
1678 c.insert(self.canvas)
1679 c.processSVG(xml, writer, context, registry, bbox)
1682 class UnicodeEngine:
1684 def __init__(self, fontname="cmr10", size=10):
1685 self.font = T1font(T1File.from_PF_bytes(config.open(fontname, [config.format.type1]).read()),
1686 AFMfile(config.open(fontname, [config.format.afm], ascii=True)))
1687 self.size = size
1689 def preamble(self):
1690 raise NotImplemented()
1692 def reset(self):
1693 raise NotImplemented()
1695 def text_pt(self, x_pt, y_pt, text, textattrs=[], texmessages=[], fontmap=None, singlecharmode=False):
1697 textattrs = attr.mergeattrs(textattrs) # perform cleans
1698 attr.checkattrs(textattrs, [textattr, trafo.trafo_pt, style.fillstyle])
1699 trafos = attr.getattrs(textattrs, [trafo.trafo_pt])
1700 fillstyles = attr.getattrs(textattrs, [style.fillstyle])
1701 textattrs = attr.getattrs(textattrs, [textattr])
1702 mathmode = bool(attr.getattrs(textattrs, [_mathmode]))
1704 if isinstance(text, MultiEngineText):
1705 text = text.unicode
1706 output = unicodetextbox_pt(x_pt, y_pt, text, self.font, self.size, mathmode=mathmode)
1707 for ta in textattrs: # reverse?!
1708 ta.apply_trafo(output)
1710 return output
1712 def text(self, x, y, *args, **kwargs):
1713 return self.text_pt(unit.topt(x), unit.topt(y), *args, **kwargs)
1716 # from pyx.font.otffile import OpenTypeFont
1718 # class OTFUnicodeText:
1720 # def __init__(self, fontname, size=10):
1721 # self.font = OpenTypeFont(config.open(fontname, [config.format.ttf]))
1722 # self.size = size
1724 # def preamble(self):
1725 # raise NotImplemented()
1727 # def reset(self):
1728 # raise NotImplemented()
1730 # def text_pt(self, x_pt, y_pt, text, textattrs=[], texmessages=[], fontmap=None, singlecharmode=False):
1731 # # def text_pt(self, x_pt, y_pt, text, *args, **kwargs):
1732 # return unicodetextbox_pt(x_pt, y_pt, text, self.font, self.size)
1734 # def text(self, x, y, *args, **kwargs):
1735 # return self.text_pt(unit.topt(x), unit.topt(y), *args, **kwargs)
1738 # old, deprecated names:
1739 texrunner = TexRunner = TexEngine
1740 latexrunner = LatexRunner = LatexEngine
1742 # module level interface documentation for autodoc
1743 # the actual values are setup by the set function
1745 #: the current :class:`MultiRunner` instance for the module level functions
1746 default_runner = None
1748 #: default_runner.preamble (bound method)
1749 preamble = None
1751 #: default_runner.text_pt (bound method)
1752 text_pt = None
1754 #: default_runner.text (bound method)
1755 text = None
1757 #: default_runner.reset (bound method)
1758 reset = None
1760 def set(engine=None, cls=None, mode=None, *args, **kwargs):
1761 """Setup a new module level :class:`MultiRunner`
1763 :param cls: the module level :class:`MultiRunner` to be used, i.e.
1764 :class:`TexRunner` or :class:`LatexRunner`
1765 :type cls: :class:`MultiRunner` object, not instance
1766 :param mode: ``"tex"`` for :class:`TexRunner` or ``"latex"`` for
1767 :class:`LatexRunner` with arbitraty capitalization, overwriting the cls
1768 value
1770 :deprecated: use the cls argument instead
1771 :type mode: str or None
1772 :param list args: args at class instantiation
1773 :param dict kwargs: keyword args at at class instantiation
1776 # note: default_runner and defaulttexrunner are deprecated
1777 global default_engine, default_runner, defaulttexrunner, reset, preamble, text, text_pt
1778 if mode is not None:
1779 logger.warning("mode setting is deprecated, use the engine argument instead")
1780 assert cls is None
1781 assert engine is None
1782 engine = {"tex": TexEngine, "latex": LatexEngine}[mode.lower()]
1783 elif cls is not None:
1784 logger.warning("cls setting is deprecated, use the engine argument instead")
1785 assert not engine
1786 engine = cls
1787 default_engine = default_runner = defaulttexrunner = engine(*args, **kwargs)
1788 preamble = default_runner.preamble
1789 text_pt = default_runner.text_pt
1790 text = default_runner.text
1791 reset = default_runner.reset
1793 # initialize default_runner
1794 set({"TexEngine": TexEngine, "LatexEngine": LatexEngine, "UnicodeEngine": UnicodeEngine}[config.get('text', 'default_engine', 'TexEngine')])
1797 def escapestring(s, replace={" ": "~",
1798 "$": "\\$",
1799 "&": "\\&",
1800 "#": "\\#",
1801 "_": "\\_",
1802 "%": "\\%",
1803 "^": "\\string^",
1804 "~": "\\string~",
1805 "<": "{$<$}",
1806 ">": "{$>$}",
1807 "{": "{$\{$}",
1808 "}": "{$\}$}",
1809 "\\": "{$\setminus$}",
1810 "|": "{$\mid$}"}):
1811 "Escapes ASCII characters such that they can be typeset by TeX/LaTeX"""
1812 i = 0
1813 while i < len(s):
1814 if not 32 <= ord(s[i]) < 127:
1815 raise ValueError("escapestring function handles ascii strings only")
1816 c = s[i]
1817 try:
1818 r = replace[c]
1819 except KeyError:
1820 i += 1
1821 else:
1822 s = s[:i] + r + s[i+1:]
1823 i += len(r)
1824 return s