root/trunk/LatexTool.py

Revision 3137, 22.1 kB (checked in by jukka, 1 year ago)

Enhanced tinymce and rewrote parser. Better latex support and nicer code.

Line 
1
2 # This file is composed from code originally from ZWiki's LatexWiki-plugin:
3 #
4 #LatexWiki - a patch to ZWiki for rendering embedded LaTeX code
5 #Copyright (C) 2001 Open Software Services <info@OpenSoftwareServices.com>
6 #Copyright (C) 2003,2004,2005 Bob McElrath <bob+latexwiki@mcelrath.org>
7 #Copyright (C) 2006 Simon Michael and Zwiki contributors <http://zwiki.org>
8 #All rights reserved, all disclaimers apply, etc.
9 #
10 #This program is free software; you can redistribute it and/or
11 #modify it under the terms of the GNU General Public License
12 #as published by the Free Software Foundation; either version 2
13 #of the License, or (at your option) any later version.
14 #
15 #This program is distributed in the hope that it will be useful,
16 #but WITHOUT ANY WARRANTY; without even the implied warranty of
17 #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 #GNU General Public License for more details.
19 #
20 #You should have received a copy of the GNU General Public License
21 #along with this program; if not, write to the Free Software
22 #Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
23
24
25 from Globals import InitializeClass
26 from OFS.SimpleItem import SimpleItem
27 from OFS.PropertyManager import PropertyManager
28 from Products.CMFCore.utils import UniqueObject
29 from AccessControl import ClassSecurityInfo
30 from DocumentTemplate.html_quote import html_quote
31 from urllib import quote_plus
32 from string import strip, join, replace
33 import os, sys, re, zLOG, string, math, popen2, select, htmllib
34 from struct import pack, unpack
35 from cgi import escape
36 from config import LATEX_IMAGES_STORAGE_PATH, EXTERNAL_LATEX_SERVICE
37 from Products.CMFCore.utils import getToolByName
38
39 #try:
40 import fcntl
41 from PIL import Image, ImageFile, ImageChops, PngImagePlugin
42 #except ImportError:
43 #    LATEX_IMAGES_STORAGE_PATH='' # No local latex for you
44
45
46 # From config we need LATEX_IMAGES_STORAGE_PATH and EXTERNAL_LATEX_SERVICE
47 # if LATEX_IMAGES_STORAGE_PATH is set, then we assume that the server is capable of rendering latex by its own power.
48 # if EXTERNAL_LATEX_SERVICE is set, then we assume it is a service like ???,
49 # where you ask for a image and image url contains the latex code.
50 # If LeMill-site becomes too popular, this will be quite a task for service provider
51 # If both are set, first we try local, then external.
52 # If none is set, we just give the latex code back
53
54 latexpath='/usr/texbin'
55
56 latexRemoveDelim = re.compile(r'^(?:(\$\$|\$(?!\$))|\\\(|\\begin{[^}]*}|\\\[)(.*)(?:\\\]|\\end{[^}]*}|\\\)|\1)$', re.MULTILINE|re.DOTALL)
57
58 errorMessage = """\n<hr/><font size="-1" color="red">
59 Some or all expressions may not have rendered properly,
60 because Latex returned the following error:<br/><pre>%s</pre></font>"""
61
62 # Actual location of images
63 workingDir = '%s/%s' % (sys.modules['__builtin__'].CLIENT_HOME,  LATEX_IMAGES_STORAGE_PATH)
64 # Default character size, if the user doesn't specify
65 defaultcharsizepx = 18
66
67 imageExtension = '.png'
68
69 class LatexSyntaxError(Exception): pass
70 class LatexRenderError(Exception): pass
71 class GhostscriptError(Exception): pass
72 class AlignError(Exception): pass
73
74
75 def unescape(s):
76     p = htmllib.HTMLParser(None)
77     p.save_bgn()
78     p.feed(s)
79     return p.save_end()
80
81 def findinpath(exe):
82     paths = [exe]
83     paths.extend( \
84         map(lambda x: os.path.join(x,exe), re.split(':', os.getenv('PATH'))))   # latexpath in possible paths is missing! 
85     for path in paths:
86         if os.access(path, os.X_OK): break
87         path = None
88     return path
89
90 # find our external programs
91 dvipngpath = findinpath('dvipng')
92 gspath     = findinpath('gs')
93 dvipspath  = findinpath('dvips') or '/usr/texbin/dvips'
94 latexpath  = findinpath('latex') or '/usr/texbin/latex'
95 print dvipngpath
96 print gspath
97 print latexpath
98 print dvipspath
99
100 charsizept = 10
101 # dvipng and tex use 72.27 points per inch, internally and thus generate the
102 # best-looking images.  Postscript uses 72 points per inch.  So if we have to
103 # use ghostscript and go through a postscript conversion, there is a resolution
104 # mismatch which puts nibs on the tops of letters for many choices of
105 # charheightpx.
106 if dvipngpath is not None:
107     ptperinch = 72.27
108 else:
109     ptperinch = 72
110 # Adjust the centerline by this many pixels, key is character height in px
111 # This list was determined experimentally.  If anyone has a better algorithm
112 # to align images, please contact me.
113 centerfudge = dict({ # positive to move up, negative to move down
114     10:0,  11:+1, 12:0,  13:0,  14:0,  15:+2, 16:0, 17:0,  18:0,  19:0,  20:0,
115     21:0,  22:0,  23:+2, 24:0,  25:0,  26:0,  27:0, 28:0,  29:0,  30:0,  31:+1,
116     32:0,  33:+1, 34:0,  35:0,  36:0,  37:0,  38:0, 39:+1, 40:0,  41:+1, 42:-1,
117     43:0,  44:0,  45:0,  46:0,  47:+1, 48:0,  49:0, 50:0,  51:0,  52:-1, 53:0,
118     54:-1, 55:+1, 56:-1, 57:+1, 58:+3, 59:-1, 60:0, 61:-1, 62:-1, 63:+1, 64:-1
119     })
120
121 latexInlinePattern = r'^(\$(?!\$)|\\\()$'
122
123 # This is only used if your wiki does not have a node LatexTemplate.
124 defaultLatexTemplate = r"""
125 \documentclass[%dpt,notitlepage]{article}
126 \usepackage{amsmath}
127 \usepackage{amsfonts}
128 \usepackage[all]{xy}
129 \newenvironment{latex}{}{}
130 \oddsidemargin -86pt
131 \headheight 0pt
132 \topmargin -96pt
133 \nofiles
134 \begin{document}
135 \pagestyle{empty}
136 %%s
137 \end{document}
138 """  % (charsizept)
139 # \topmargin -96pt
140
141
142 class LatexTool(PropertyManager, UniqueObject, SimpleItem):
143     """ Tool for LaTex math support """
144
145     id = 'latex_tool'
146     meta_type = 'LatexTool'
147     security = ClassSecurityInfo()
148     plone_tool = 1
149     toolicon = 'skins/lemill/tool.gif'
150     __allow_access_to_unprotected_subobjects__ = 1
151
152
153
154
155
156
157     # This is the method you should call.
158
159     def getImageFor(self, latexCode, charheightpx=17, delim=True):
160         if LATEX_IMAGES_STORAGE_PATH:
161             try:
162             #print latexCode
163                 return self.getLocalImageFor(latexCode, charheightpx, delim)
164             except (IOError, LatexSyntaxError), data:
165             #    print data
166                 errors = str(data)
167                 self.log(errors, 'LatexSyntaxError')
168         if EXTERNAL_LATEX_SERVICE:
169             return self.getRemoteImageFor(latexCode, delim)           
170         else:
171             return latexCode           
172
173     def getLocalImageFor(self, latexCode, charheightpx=17, delim=True):   
174         print 'using local latex'
175         portal_url = getToolByName(self, 'portal_url')
176         preamble, postamble = '', ''
177         width, height = '', ''
178         imageFile = self.fileNameFor(latexCode, charheightpx, '.png')
179         if not os.path.exists(os.path.join(workingDir, imageFile)):
180             errors = self.renderNonexistingImages([latexCode], charheightpx,  0.0, 1.03) # alignfudge orig. 0.0
181         imageUrl = '%s/%s/%s' % (portal_url(), LATEX_IMAGES_STORAGE_PATH, imageFile)
182         width, height = self.getPngSize(os.path.join(workingDir, imageFile))
183         if delim:
184             alt = html_quote(latexRemoveDelim.match(latexCode).group(2))
185         else:
186             alt=html_quote(latexCode)
187         return '%s<img alt="%s" class="equation" src="%s" width="%s" height="%s"/>%s' %(preamble,
188                                                                 alt,
189                                                                 imageUrl,
190                                                                 width,
191                                                                 height,
192                                                                 postamble)
193
194     def getRemoteImageFor(self, latexCode, delim=True):
195         print 'using remote latex'
196         code=unescape(latexCode)
197         if delim:
198             code=latexRemoveDelim.match(code).group(2)
199         alt= html_quote(code)
200         src=''.join((EXTERNAL_LATEX_SERVICE,quote_plus(code)))
201         return '<img alt="%s" class="equation" src="%s" border="0" align="middle" />' % (alt, src)
202
203    
204     def fileNameFor(self, latexCode, size, extension=''):
205         return '%s-%spx%s' %(abs(hash(latexCode)), size, extension)
206    
207     def getPngSize(self, fname,
208                    magicBytes=pack('!BBBBBBBB', 137, 80, 78, 71, 13, 10, 26, 10)):
209         f = file(fname, 'r')
210         buf = f.read(24)
211         f.close()
212         assert buf[:8] == magicBytes, 'in getPngSize, file not a PNG!'
213         return tuple(map(int, unpack('!LL', buf[16:24])))
214    
215     def log(self, message,summary='',severity=0):
216             zLOG.LOG('LatexWikiDebugLog',severity,summary,message)
217    
218     # Make our file descriptors nonblocking so that reading doesn't hang.
219     def makeNonBlocking(self, f):
220         fl = fcntl.fcntl(f.fileno(), fcntl.F_GETFL)
221         fcntl.fcntl(f.fileno(), fcntl.F_SETFL, fl | os.O_NONBLOCK)
222        
223     def runCommand(self, cmdLine, input=None):
224         program = popen2.Popen3('cd %s; '%(workingDir) + cmdLine, 1)
225         if input:
226             program.tochild.write(input)
227         program.tochild.close()
228         self.makeNonBlocking(program.fromchild)
229         self.makeNonBlocking(program.childerr)
230         stderr = []
231         stdout = []
232         erreof = False
233         outeof = False
234         while(not (erreof and outeof)):
235             readme, writme, xme = select.select([program.fromchild, program.childerr], [], [])
236             for output in readme:
237                 if(output == program.fromchild):
238                     text = program.fromchild.read()
239                     if(text == ''): outeof = True
240                     else: stdout.append(text)
241                 elif(output == program.childerr):
242                     text = program.childerr.read()
243                     if(text == ''): erreof = True
244                     else: stderr.append(text)
245         status = program.wait()
246         error = os.WEXITSTATUS(status) or not os.WIFEXITED(status)
247         return error, string.join(stdout, ''), string.join(stderr, '')
248            
249     # methods from latexWrapper.py begin here
250
251     def imageDoesNotExist(self, code, charheightpx):
252         return not os.path.exists(os.path.join(workingDir,
253             self.fileNameFor(code, charheightpx, imageExtension)))
254    
255     def renderNonexistingImages(self, latexCodeList, charheightpx, alignfudge, resfudge, **kw):
256         """ take a list of strings of latex code, render the
257         images that don't already exist.
258         """
259         print 'LATEX: rendering images'
260         latexTemplate = (kw.get('latexTemplate', defaultLatexTemplate) or
261                          defaultLatexTemplate)
262         m = re.search(r'\\documentclass\[[^\]]*?(\d+)pt[^\]]*?\]', \
263             latexTemplate)
264         if m:
265             charsizept = int(m.group(1))
266         else:
267             charsizept = 10
268         res = charheightpx*ptperinch/charsizept*resfudge
269         errors = ""
270         latexCodeList=list(set(latexCodeList)) # removes duplicates
271         codeToRender = filter(lambda x: self.imageDoesNotExist(x, charheightpx), latexCodeList)
272         if (not codeToRender): return
273         #unifiedCode = re.sub(r'^(\$|\\\()', r'\1|~ ', codeToRender[0])
274         #for code in codeToRender[1:len(codeToRender)]:
275         #    unifiedCode = unifiedCode + '\n\\newpage\n' + re.sub(r'^(\$|\\\()', r'\1|~ ', code)
276         ### PATCH FOR LEMILL: JUST RENDER THE FIRST SNIPPET, THERE SHOULD NOT BE MORE
277         unifiedCode=codeToRender[0]
278         try:
279             self.runLatex(unifiedCode, res, charheightpx, latexTemplate)
280         except LatexSyntaxError, data:
281            errors = str(data)
282            self.log(errors, 'LatexSyntaxError')
283            # FIXME translate latex line number to source line number
284            return escape(errors)
285    
286         fName = self.fileNameFor(unifiedCode, charheightpx)
287         self.dviPng(fName, res)
288         for code, i in map(None, codeToRender, range(0, len(codeToRender))):
289             newFileName = self.fileNameFor(code, charheightpx, imageExtension)
290             imname = '%s-%03d.png'%(fName,i+1)
291
292             # The next clause causes problems, so I make it always False.
293             if False and re.match(r'^(?:\$|\\\()', code): # FIXME make dvipng do the alpha properly
294                 im = Image.open(os.path.join(workingDir, imname))
295                 try:
296                     im = self.align(im, charheightpx, alignfudge) # returns an RGBA image
297                 except (AlignError, ValueError), data:
298                     raise LatexRenderError(str(data) + '\nThe code was:\n' + \
299                         code+ '\nin the file %s'%(os.path.join(workingDir, imname)))
300                 if im.mode != 'RGBA':
301                     alpha = ImageChops.invert(im.convert('L'))
302                     i = im.putalpha(alpha)
303                 im.save(os.path.join(workingDir, newFileName), "PNG")
304             else:
305                 os.rename(os.path.join(workingDir, imname), os.path.join(workingDir, newFileName))
306         os.system('cd %s; rm -f *.log *.aux *.tex *.pdf *.dvi *.ps %s-???.png'%(workingDir, fName))
307         return escape(errors)
308    
309     def runLatex(self, code, res, charheightpx, latexTemplate):
310         def ensureWorkingDirectory(path):
311             """Ensure this directory exists and is writable."""
312             if not os.access(path,os.F_OK): os.mkdir(path)
313             if not os.access(path,os.W_OK): os.system('chmod u+rwx %s' % path)
314    
315         texfileName = self.fileNameFor(code, charheightpx, '.tex')
316         dvifileName = self.fileNameFor(code, charheightpx, '.dvi')
317         psfileName = self.fileNameFor(code, charheightpx, '.ps')
318         cmdLine = '%s %s' %(latexpath, texfileName)
319    
320         ensureWorkingDirectory(workingDir)
321         file = open(os.path.join(workingDir, texfileName), 'w')
322         file.write(latexTemplate %(code,))
323         file.close()
324         err, stdout, stderr = self.runCommand(cmdLine)
325        
326         if err:
327             out = stderr + '\n' + stdout+'\n'+ cmdLine
328             err = re.search('!.*\?', out, re.MULTILINE+re.DOTALL)
329             if err:
330                 out = err.group(0)
331     # FIXME translate latex line numbers to source line numbers
332             raise LatexSyntaxError(out)
333    
334     def dviPng(self, fName, res):
335         input, output = fName+'.dvi', fName+'-%03d.png'
336         gspngfname = fName+'-gs-%03d.png'
337         psfname = fName+'-gs'; i=1
338         # '--truecolor -bg Transparent' generates RGB images with transparent pixel
339         # (not alpha channel) but it's close...
340         ppopt = ''
341         if dvipngpath is not None:
342             #cmdLine = '%s --truecolor -bg Transparent -picky -D %f -Ttight -v* -o %s %s'%\
343             #    (dvipngpath, res, output % i, input)
344             cmdLine = '%s -bg Transparent -picky -D %f -T tight -v -o %s %s'%\
345                 (dvipngpath, res, output % i, input)
346             err, stdout, stderr = self.runCommand(cmdLine)
347             ppredo = []           
348             if not err: return
349             #print err
350             #print stdout
351             #print stderr
352             # dvipng -picky will give the following message on pages it cannot render
353             # (usually due to the use of postscript specials).  For that we fall
354             # through to ghostscript
355             matcher = re.finditer(r'\[(\d+) not rendered\]', stdout)
356             for m in matcher:
357                 if ppredo: ppredo += ','
358                 ppredo.append(m.group(1))           
359             if ppredo:
360                 ppopt = '-pp ' + string.join(ppredo,',')
361         cmdLine = '%s %s -R -D %f -o %s %s'%(dvipspath, ppopt, res, psfname+'.ps', input)
362         #print cmdLine
363         err, stdout, stderr = self.runCommand(cmdLine)
364         if err:
365             self.log('%s\n%s\n%s\n'%(err, stdout, stderr), 'DVIPSError')
366             raise 'DVIPSError %s' % stderr+'\n'+stdout
367         if not ppopt:
368             ppredo = range(1,len(re.findall(r'\[\d+\]', stderr))+1)
369         err = self.runGhostscript(psfname, res, 'pngalpha')
370         self.center(psfname, res)
371         for page in ppredo:
372             oldfname = os.path.join(workingDir, gspngfname%i)
373             newfname = os.path.join(workingDir, output%int(page))
374             os.rename(oldfname, newfname)
375             i += 1
376    
377     def runGhostscript(self, fName, res, device):
378         input, output = fName+'.ps', fName+'-%03d.png'
379         cmdLine = '%s -dDOINTERPOLATE -dTextAlphaBits=4 '%gspath + \
380                   '-dGraphicsAlphaBits=4 -r%f -sDEVICE=%s ' + \
381                   '-dBATCH -dNOPAUSE -dQUIT -sOutputFile=%s %s '
382         cmdLine = cmdLine %(res, device, output, input)
383         err, stdout, stderr = self.runCommand(cmdLine)
384         if err:
385             log('%s\n%s\n%s\n'%(err, stdout, stderr), 'GhostscriptError')
386             raise GhostscriptError(stderr+'\n'+stdout)
387         return stderr # when using bbox, BoundingBox is on stderr
388     
389     # assumes png's already created
390     def center(self, fName, res):
391         bboxes = re.split('\n', self.runGhostscript(fName, res, 'bbox'))
392         pngfname = fName+'-%03d.png'
393         for i in range(0, len(bboxes)/2):
394             file = pngfname%(i+1)
395             start_x, start_y, end_x, end_y = map(float,
396                 re.match(r'%%HiResBoundingBox: ([0-9\.]+) ([0-9\.]+) ([0-9\.]+) ([0-9\.]+)',
397                     bboxes[2*i+1]).groups())
398             xsize = int(round(((end_x - start_x) * res)/ptperinch))
399             ysize = int(round(((end_y - start_y) * res)/ptperinch))
400             if (xsize <= 0): xsize = 1
401             if (ysize <= 0): ysize = 1
402             start_x = int(round(start_x*res/ptperinch))
403             start_y = int(round(start_y*res/ptperinch))
404             im = Image.open(os.path.join(workingDir, file))
405             cropdim = (start_x, im.size[1]-start_y-ysize, start_x+xsize, im.size[1]-start_y)
406             cropdim = map(int, map(round, cropdim))
407             im = im.crop(cropdim)
408             im2 = Image.new('RGBA', im.size, (255,255,255))
409             im2.paste(im, (0, 0))
410             if im.mode != 'RGBA':
411                 alpha = ImageChops.invert(im2.convert('L'))  # Image should already have an alpha
412                 im3 = Image.new('RGBA', im.size, (0,0,0))
413                 im3.putalpha(alpha)
414                 im2 = im3
415             im2.save(os.path.join(workingDir, file), "PNG")
416    
417     def align(self, im, charheightpx=0, alignfudge=0):
418         dotstartx = -1; dotendx = -1; dotstarty = -1; dotendy = -1
419         widentop = 0; widenbottom = 0; letterstartx = -1; chopx = 0
420         if im.mode == 'P':
421             white = 0
422         elif im.mode == 'RGB':
423             white = (255,255,255)
424         elif im.mode == 'RGBA':
425             white = (254,254,254,0) # as output by ghostscript pngalpha device
426         elif im.mode == 'L':
427             white = 255 # FIXME I think
428         for x in range(0,im.size[0]):  # Try to find the leading dot
429             if(dotendy < 0) :
430                 for y in range(0, im.size[1]):
431                     pixel=im.getpixel((x,y))
432                     if(dotstarty >= 0 and dotendy < 0):
433                         if(pixel == white):
434                             dotendy = y
435                             break
436                     if(dotstarty < 0 and pixel != white):
437                         dotstartx = x
438                         dotstarty = y
439                 else:
440                     if dotstarty >= 0 and dotendy < 0:
441                         dotendy = im.size[1]
442             elif(dotendx < 0):
443                 maybedotendx = x
444                 for y in range(dotstarty, dotendy):
445                     pixel=im.getpixel((x,y))
446                     if pixel != white:
447                         maybedotendx = -1
448                 if maybedotendx > 0:
449                     dotendx = x
450             else:               
451                 for y in range(0,im.size[1]):
452                     pixel=im.getpixel((x,y))
453                     if pixel != white:
454                         letterstartx = x
455                         break
456                 if letterstartx>0: break
457         else: # failed to find letterstartx
458             #print 'dotstartx=%d, dotendx=%d, dotstarty=%d, dotendy=%d, letterstartx=%d\n' % (dotstartx, dotendx, dotstarty, dotendy, letterstartx)
459             self.log('dotstartx=%d, dotendx=%d, dotstarty=%d, dotendy=%d, letterstartx=%d\n'
460                 %(dotstartx, dotendx, dotstarty, dotendy, letterstartx))
461             self.log('Unable to find dot. (size=%dx%d)\n'%(im.size[0],im.size[1]), 'renderNonExistingImages')
462             raise AlignError('Image appears to be blank or not have an alignment dot.')
463         centerline = (dotendy-dotstarty)/2.0    # increase centerline to move char up WRT text
464         dotcenter = (dotendy-dotstarty)*7.0/144.0
465         centerline += dotcenter
466         if centerfudge.has_key(charheightpx):
467             centerline += centerfudge[charheightpx]/2.0
468         # if dot is not pixel-aligned, take that into account
469         # sum pixels above and below (dotendy-dotstarty)/2
470         dottophalf = 0
471         dotlinesize = dotendx-dotstartx
472         dottoplines = 0
473         for y in range(dotstarty, int(math.ceil(dotstarty+(dotendy-1-dotstarty)/2.0))):
474             dottoplines += 1
475             for x in range(dotstartx, dotendx):
476                 dottophalf += self.cabs(im.getpixel((x,y)))
477             break
478         else:
479             dottophalf = 1 # dot was 1px high
480         dotbottomhalf = 0
481         for y in range(dotendy-1, dotstarty+(dotendy-1-dotstarty)/2,-1):
482             for x in range(dotstartx, dotendx):
483                 dotbottomhalf += self.cabs(im.getpixel((x,y)))
484             break
485         else:
486             dotbottomhalf = 1 # dot was 1px high
487         if(dottophalf != 0.0 and dotbottomhalf != 0):
488             dotpixmiss = float(dottophalf-dotbottomhalf)/(dottophalf+dotbottomhalf)
489         else:
490             dotpixmiss = 0.0
491         centerline += dotpixmiss
492         centerline += alignfudge # user parameter -- FIXME remove?
493         bottomsize = im.size[1]-centerline               # pixels below midline
494         topsize = centerline                             # pixels above midline
495         if(topsize > bottomsize):
496             newheight = 2*topsize
497             widenbottom = topsize - topsize
498         else:
499             newheight = 2*bottomsize
500             widentop = bottomsize - topsize
501         chopx = letterstartx-1
502         newheight= int(newheight)
503         widentop = 0 #int(widentop) #SKWM broken
504         im2 = Image.new('RGBA', (im.size[0]-chopx,newheight), (255,255,255))
505         im2.paste(im,(-chopx,widentop,im.size[0]-chopx,im.size[1]-widentop))
506         return im2
507    
508     def cabs(self, A):
509         sq = 0.0
510         if type(A) == type(()):
511             for i in range(0,3):
512                 sq += A[i]*A[i]
513             if len(A) == 4:
514                 return math.sqrt(sq)*(A[3]/255.0)
515             else:
516                 return math.sqrt(sq)
517         else:
518             return A
519    
520 InitializeClass(LatexTool)
Note: See TracBrowser for help on using the browser.