root/trunk/src/htmltextview.py

Revision 10549, 35.3 kB (checked in by asterix, 6 weeks ago)

revert thorstenp patches for now. They introduce bugs.

Line 
1# -*- coding:utf-8 -*-
2## src/htmltextview.py
3##
4## Copyright (C) 2005 Gustavo J. A. M. Carneiro
5## Copyright (C) 2006 Santiago Gala
6## Copyright (C) 2006-2007 Jean-Marie Traissard <jim AT lapin.org>
7## Copyright (C) 2006-2008 Yann Leboulanger <asterix AT lagaule.org>
8## Copyright (C) 2007 Nikos Kouremenos <kourem AT gmail.com>
9## Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
10##                    Julien Pivotto <roidelapluie AT gmail.com>
11##                    Stephan Erb <steve-e AT h3c.de>
12##
13## This file is part of Gajim.
14##
15## Gajim is free software; you can redistribute it and/or modify
16## it under the terms of the GNU General Public License as published
17## by the Free Software Foundation; version 3 only.
18##
19## Gajim is distributed in the hope that it will be useful,
20## but WITHOUT ANY WARRANTY; without even the implied warranty of
21## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22## GNU General Public License for more details.
23##
24## You should have received a copy of the GNU General Public License
25## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
26##
27
28'''
29A gtk.TextView-based renderer for XHTML-IM, as described in:
30  http://www.jabber.org/jeps/jep-0071.html
31
32Starting with the version posted by Gustavo Carneiro,
33I (Santiago Gala) am trying to make it more compatible
34with the markup that docutils generate, and also more
35modular.
36
37'''
38
39import gobject
40import pango
41import gtk
42import xml.sax, xml.sax.handler
43import re
44import warnings
45from cStringIO import StringIO
46import socket
47import time
48import urllib2
49import operator
50
51if __name__ == '__main__':
52        from common import i18n
53from common import gajim
54
55import tooltips
56
57
58__all__ = ['HtmlTextView']
59
60whitespace_rx = re.compile('\\s+')
61allwhitespace_rx = re.compile('^\\s*$')
62
63# pixels = points * display_resolution
64display_resolution = 0.3514598*(gtk.gdk.screen_height() /
65                                        float(gtk.gdk.screen_height_mm()))
66
67# embryo of CSS classes
68classes = {
69        #'system-message':';display: none',
70        'problematic':';color: red',
71}
72
73# styles for elements
74element_styles = {
75                'u'                     : ';text-decoration: underline',
76                'em'            : ';font-style: oblique',
77                'cite'          : '; background-color:rgb(170,190,250); font-style: oblique',
78                'li'            : '; margin-left: 1em; margin-right: 10%',
79                'strong'        : ';font-weight: bold',
80                'pre'           : '; background-color:rgb(190,190,190); font-family: monospace; white-space: pre; margin-left: 1em; margin-right: 10%',
81                'kbd'           : ';background-color:rgb(210,210,210);font-family: monospace',
82                'blockquote': '; background-color:rgb(170,190,250); margin-left: 2em; margin-right: 10%',
83                'dt'            : ';font-weight: bold; font-style: oblique',
84                'dd'            : ';margin-left: 2em; font-style: oblique'
85}
86# no difference for the moment
87element_styles['dfn'] = element_styles['em']
88element_styles['var'] = element_styles['em']
89# deprecated, legacy, presentational
90element_styles['tt']  = element_styles['kbd']
91element_styles['i']   = element_styles['em']
92element_styles['b']   = element_styles['strong']
93
94'''
95==========
96  JEP-0071
97==========
98
99This Integration Set includes a subset of the modules defined for
100XHTML 1.0 but does not redefine any existing modules, nor
101does it define any new modules. Specifically, it includes the
102following modules only:
103
104- Structure
105- Text
106 
107  * Block
108   
109    phrasal
110       addr, blockquote, pre
111    Struc
112       div,p
113    Heading
114       h1, h2, h3, h4, h5, h6
115   
116  * Inline
117   
118    phrasal
119       abbr, acronym, cite, code, dfn, em, kbd, q, samp, strong, var
120    structural
121       br, span
122 
123- Hypertext (a)
124- List (ul, ol, dl)
125- Image (img)
126- Style Attribute
127     
128Therefore XHTML-IM uses the following content models:
129
130  Block.mix
131            Block-like elements, e.g., paragraphs
132  Flow.mix
133            Any block or inline elements
134  Inline.mix
135            Character-level elements
136  InlineNoAnchor.class
137                        Anchor element
138  InlinePre.mix
139            Pre element
140
141XHTML-IM also uses the following Attribute Groups:
142
143Core.extra.attrib
144        TBD
145I18n.extra.attrib
146        TBD
147Common.extra
148        style
149
150
151...
152#block level:
153#Heading    h
154#           ( pres           = h1 | h2 | h3 | h4 | h5 | h6 )
155#Block      ( phrasal        = address | blockquote | pre )
156#NOT           ( presentational = hr )
157#           ( structural     = div | p )
158#other:     section
159#Inline     ( phrasal        = abbr | acronym | cite | code | dfn | em | kbd | q | samp | strong | var )
160#NOT        ( presentational =  b  | big | i | small | sub | sup | tt )
161#           ( structural     =  br | span )
162#Param/Legacy    param, font, basefont, center, s, strike, u, dir, menu, isindex
163#
164'''
165
166BLOCK_HEAD = set(( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', ))
167BLOCK_PHRASAL = set(( 'address', 'blockquote', 'pre', ))
168BLOCK_PRES = set(( 'hr', )) #not in xhtml-im
169BLOCK_STRUCT = set(( 'div', 'p', ))
170BLOCK_HACKS = set(( 'table', 'tr' )) # at the very least, they will start line ;)
171BLOCK = BLOCK_HEAD.union(BLOCK_PHRASAL).union(BLOCK_STRUCT).union(BLOCK_PRES).union(BLOCK_HACKS)
172
173INLINE_PHRASAL = set('abbr, acronym, cite, code, dfn, em, kbd, q, samp, strong, var'.split(', '))
174INLINE_PRES = set('b, i, u, tt'.split(', ')) #not in xhtml-im
175INLINE_STRUCT = set('br, span'.split(', '))
176INLINE = INLINE_PHRASAL.union(INLINE_PRES).union(INLINE_STRUCT)
177
178LIST_ELEMS = set( 'dl, ol, ul'.split(', '))
179
180for name in BLOCK_HEAD:
181        num = eval(name[1])
182        size = (num-1) // 2
183        weigth = (num - 1) % 2
184        element_styles[name] = '; font-size: %s; %s' % ( ('large', 'medium', 'small')[size],
185                                                                                                        ('font-weight: bold', 'font-style: oblique')[weigth],
186                                                                                          )
187
188
189def build_patterns(view, config, interface):
190        # extra, rst does not mark _underline_ or /it/ up
191        # actually <b>, <i> or <u> are not in the JEP-0071, but are seen in the wild
192        basic_pattern = r'(?<!\w|\<|/|:)' r'/[^\s/]' r'([^/]*[^\s/])?' r'/(?!\w|/|:)|'\
193                                        r'(?<!\w)' r'_[^\s_]' r'([^_]*[^\s_])?' r'_(?!\w)'
194        view.basic_pattern_re = re.compile(basic_pattern)
195        # emoticons
196        emoticons_pattern = ''
197        try:
198                if config.get('emoticons_theme'):
199                        # When an emoticon is bordered by an alpha-numeric character it is NOT
200                        # expanded.  e.g., foo:) NO, foo :) YES, (brb) NO, (:)) YES, etc.
201                        # We still allow multiple emoticons side-by-side like :P:P:P
202                        # sort keys by length so :qwe emot is checked before :q
203                        keys = interface.emoticons.keys()
204                        keys.sort(interface.on_emoticon_sort)
205                        emoticons_pattern_prematch = ''
206                        emoticons_pattern_postmatch = ''
207                        emoticon_length = 0
208                        for emoticon in keys: # travel thru emoticons list
209                                emoticon_escaped = re.escape(emoticon) # espace regexp metachars
210                                emoticons_pattern += emoticon_escaped + '|'# | means or in regexp
211                                if (emoticon_length != len(emoticon)):
212                                        # Build up expressions to match emoticons next to other emoticons
213                                        emoticons_pattern_prematch  = emoticons_pattern_prematch[:-1]  + ')|(?<='
214                                        emoticons_pattern_postmatch = emoticons_pattern_postmatch[:-1] + ')|(?='
215                                        emoticon_length = len(emoticon)
216                                emoticons_pattern_prematch += emoticon_escaped  + '|'
217                                emoticons_pattern_postmatch += emoticon_escaped + '|'
218                        # We match from our list of emoticons, but they must either have
219                        # whitespace, or another emoticon next to it to match successfully
220                        # [\w.] alphanumeric and dot (for not matching 8) in (2.8))
221                        emoticons_pattern = '|' + \
222                        '(?:(?<![\w.]' + emoticons_pattern_prematch[:-1]   + '))' + \
223                        '(?:'       + emoticons_pattern[:-1]            + ')'  + \
224                        '(?:(?![\w.]'  + emoticons_pattern_postmatch[:-1]  + '))'
225        except Exception:
226                pass
227
228        view.emot_pattern_re = re.compile(emoticons_pattern, re.IGNORECASE)
229        # because emoticons match later (in the string) they need to be after
230        # basic matches that may occur earlier
231        emot_and_basic_pattern = basic_pattern + emoticons_pattern
232        view.emot_and_basic_re = re.compile(emot_and_basic_pattern, re.IGNORECASE)
233
234
235def _parse_css_color(color):
236        '''_parse_css_color(css_color) -> gtk.gdk.Color'''
237        if color.startswith('rgb(') and color.endswith(')'):
238                r, g, b = [int(c)*257 for c in color[4:-1].split(',')]
239                return gtk.gdk.Color(r, g, b)
240        else:
241                return gtk.gdk.color_parse(color)
242
243def style_iter(style):
244        return (map(lambda x:x.strip(),item.split(':', 1)) for item in style.split(';') if len(item.strip()))
245       
246
247class HtmlHandler(xml.sax.handler.ContentHandler):
248        """A handler to display html to a gtk textview.
249
250        It keeps a stack of "style spans" (start/end element pairs)
251        and a stack of list counters, for nested lists.
252        """
253        def __init__(self, textview, startiter):
254                xml.sax.handler.ContentHandler.__init__(self)
255                self.textbuf = textview.get_buffer()
256                self.textview = textview
257                self.iter = startiter
258                self.text = ''
259                self.starting=True
260                self.preserve = False
261                self.styles = [] # a gtk.TextTag or None, for each span level
262                self.list_counters = [] # stack (top at head) of list
263                                                                # counters, or None for unordered list
264
265        def _parse_style_color(self, tag, value):
266                color = _parse_css_color(value)
267                tag.set_property('foreground-gdk', color)
268
269        def _parse_style_background_color(self, tag, value):
270                color = _parse_css_color(value)
271                tag.set_property('background-gdk', color)
272                tag.set_property('paragraph-background-gdk', color)
273
274
275        #FIXME: when we migrate to 2.10 rm this
276        if gtk.gtk_version >= (2, 8, 5) or gobject.pygtk_version >= (2, 8, 1):
277
278                def _get_current_attributes(self):
279                        attrs = self.textview.get_default_attributes()
280                        self.iter.backward_char()
281                        self.iter.get_attributes(attrs)
282                        self.iter.forward_char()
283                        return attrs
284               
285        else:
286               
287                # Workaround http://bugzilla.gnome.org/show_bug.cgi?id=317455
288                def _get_current_style_attr(self, propname, comb_oper=None):
289                        tags = [tag for tag in self.styles if tag is not None]
290                        tags.reverse()
291                        is_set_name = propname + '-set'
292                        value = None
293                        for tag in tags:
294                                if tag.get_property(is_set_name):
295                                        if value is None:
296                                                value = tag.get_property(propname)
297                                                if comb_oper is None:
298                                                        return value
299                                        else:
300                                                value = comb_oper(value, tag.get_property(propname))
301                        return value
302
303                class _FakeAttrs(object):
304                        __slots__ = ('font', 'font_scale')
305
306                def _get_current_attributes(self):
307                        attrs = self._FakeAttrs()
308                        attrs.font_scale = self._get_current_style_attr('scale',
309                                                                                                                        operator.mul)
310                        if attrs.font_scale is None:
311                                attrs.font_scale = 1.0
312                        attrs.font = self._get_current_style_attr('font-desc')
313                        if attrs.font is None:
314                                attrs.font = self.textview.style.font_desc
315                        return attrs
316
317
318        def __parse_length_frac_size_allocate(self, textview, allocation,
319                                                                                  frac, callback, args):
320                callback(allocation.width*frac, *args)
321
322        def _parse_length(self, value, font_relative, block_relative, minl, maxl, callback, *args):
323                '''Parse/calc length, converting to pixels, calls callback(length, *args)
324                when the length is first computed or changes'''
325                if value.endswith('%'):
326                        val = float(value[:-1])
327                        sign = cmp(val,0)
328                        # limits: 1% to 500%
329                        val = sign*max(1,min(abs(val),500))
330                        frac = val/100 
331                        if font_relative:
332                                attrs = self._get_current_attributes()
333                                font_size = attrs.font.get_size() / pango.SCALE
334                                callback(frac*display_resolution*font_size, *args)
335                        elif block_relative:
336                                # CSS says 'Percentage values: refer to width of the closest
337                                #           block-level ancestor'
338                                # This is difficult/impossible to implement, so we use
339                                # textview width instead; a reasonable approximation..
340                                alloc = self.textview.get_allocation()
341                                self.__parse_length_frac_size_allocate(self.textview, alloc,
342                                                                                                           frac, callback, args)
343                                self.textview.connect('size-allocate',
344                                                                          self.__parse_length_frac_size_allocate,
345                                                                          frac, callback, args)
346                        else:
347                                callback(frac, *args)
348                        return
349
350                val = float(value[:-2])
351                sign = cmp(val,0)
352                # validate length
353                val = sign*max(minl,min(abs(val*display_resolution),maxl))
354                if value.endswith('pt'): # points
355                        callback(val*display_resolution, *args)
356
357                elif value.endswith('em'): # ems, the width of the element's font
358                        attrs = self._get_current_attributes()
359                        font_size = attrs.font.get_size() / pango.SCALE
360                        callback(val*display_resolution*font_size, *args)
361
362                elif value.endswith('ex'): # x-height, ~ the height of the letter 'x'
363                        # FIXME: figure out how to calculate this correctly
364                        #        for now 'em' size is used as approximation
365                        attrs = self._get_current_attributes()
366                        font_size = attrs.font.get_size() / pango.SCALE
367                        callback(val*display_resolution*font_size, *args)
368
369                elif value.endswith('px'): # pixels
370                        callback(val, *args)
371
372                else:
373                        try:
374                                # TODO: isn't "no units" interpreted as pixels?
375                                val = int(value)
376                                sign = cmp(val,0)
377                                # validate length
378                                val = sign*max(minl,min(abs(val),maxl))
379                                callback(val, *args)
380                        except Exception:
381                                warnings.warn('Unable to parse length value "%s"' % value)
382               
383        def __parse_font_size_cb(length, tag):
384                tag.set_property('size-points', length/display_resolution)
385        __parse_font_size_cb = staticmethod(__parse_font_size_cb)
386
387        def _parse_style_display(self, tag, value):
388                if value == 'none':
389                        tag.set_property('invisible','true')
390                # FIXME: display: block, inline
391
392        def _parse_style_font_size(self, tag, value):
393                try:
394                        scale = {
395                                'xx-small': pango.SCALE_XX_SMALL,
396                                'x-small': pango.SCALE_X_SMALL,
397                                'small': pango.SCALE_SMALL,
398                                'medium': pango.SCALE_MEDIUM,
399                                'large': pango.SCALE_LARGE,
400                                'x-large': pango.SCALE_X_LARGE,
401                                'xx-large': pango.SCALE_XX_LARGE,
402                                } [value]
403                except KeyError:
404                        pass
405                else:
406                        attrs = self._get_current_attributes()
407                        tag.set_property('scale', scale / attrs.font_scale)
408                        return
409                if value == 'smaller':
410                        tag.set_property('scale', pango.SCALE_SMALL)
411                        return
412                if value == 'larger':
413                        tag.set_property('scale', pango.SCALE_LARGE)
414                        return
415                # font relative (5 ~ 4pt, 110 ~ 72pt)
416                self._parse_length(value, True, False, 5, 110, self.__parse_font_size_cb, tag)
417
418        def _parse_style_font_style(self, tag, value):
419                try:
420                        style = {
421                                'normal': pango.STYLE_NORMAL,
422                                'italic': pango.STYLE_ITALIC,
423                                'oblique': pango.STYLE_OBLIQUE,
424                                } [value]
425                except KeyError:
426                        warnings.warn('unknown font-style %s' % value)
427                else:
428                        tag.set_property('style', style)
429
430        def __frac_length_tag_cb(self,length, tag, propname):
431                styles = self._get_style_tags()
432                if styles:
433                        length += styles[-1].get_property(propname)
434                tag.set_property(propname, length)
435        #__frac_length_tag_cb = staticmethod(__frac_length_tag_cb)
436               
437        def _parse_style_margin_left(self, tag, value):
438                # block relative
439                self._parse_length(value, False, True, 1, 1000, self.__frac_length_tag_cb,
440                                                   tag, 'left-margin')
441
442        def _parse_style_margin_right(self, tag, value):
443                # block relative
444                self._parse_length(value, False, True, 1, 1000, self.__frac_length_tag_cb,
445                                                   tag, 'right-margin')
446
447        def _parse_style_font_weight(self, tag, value):
448                # TODO: missing 'bolder' and 'lighter'
449                try:
450                        weight = {
451                                '100': pango.WEIGHT_ULTRALIGHT,
452                                '200': pango.WEIGHT_ULTRALIGHT,
453                                '300': pango.WEIGHT_LIGHT,
454                                '400': pango.WEIGHT_NORMAL,
455                                '500': pango.WEIGHT_NORMAL,
456                                '600': pango.WEIGHT_BOLD,
457                                '700': pango.WEIGHT_BOLD,
458                                '800': pango.WEIGHT_ULTRABOLD,
459                                '900': pango.WEIGHT_HEAVY,
460                                'normal': pango.WEIGHT_NORMAL,
461                                'bold': pango.WEIGHT_BOLD,
462                                } [value]
463                except KeyError:
464                        warnings.warn('unknown font-style %s' % value)
465                else:
466                        tag.set_property('weight', weight)
467
468        def _parse_style_font_family(self, tag, value):
469                tag.set_property('family', value)
470
471        def _parse_style_text_align(self, tag, value):
472                try:
473                        align = {
474                                'left': gtk.JUSTIFY_LEFT,
475                                'right': gtk.JUSTIFY_RIGHT,
476                                'center': gtk.JUSTIFY_CENTER,
477                                'justify': gtk.JUSTIFY_FILL,
478                                } [value]
479                except KeyError:
480                        warnings.warn('Invalid text-align:%s requested' % value)
481                else:
482                        tag.set_property('justification', align)
483       
484        def _parse_style_text_decoration(self, tag, value):
485                values = value.split(' ')
486                if 'none' in values:
487                        tag.set_property('underline', pango.UNDERLINE_NONE)
488                        tag.set_property('strikethrough', False)
489                if 'underline' in values:
490                        tag.set_property('underline', pango.UNDERLINE_SINGLE)
491                else:
492                        tag.set_property('underline', pango.UNDERLINE_NONE)
493                if 'line-through' in values:
494                        tag.set_property('strikethrough', True)
495                else:
496                        tag.set_property('strikethrough', False)
497                if 'blink' in values:
498                        warnings.warn('text-decoration:blink not implemented')
499                if 'overline' in values:
500                        warnings.warn('text-decoration:overline not implemented')
501       
502        def _parse_style_white_space(self, tag, value):
503                if value == 'pre':
504                        tag.set_property('wrap_mode', gtk.WRAP_NONE)
505                elif value == 'normal':
506                        tag.set_property('wrap_mode', gtk.WRAP_WORD)
507                elif value == 'nowrap':
508                        tag.set_property('wrap_mode', gtk.WRAP_NONE)
509
510        def __length_tag_cb(self, value, tag, propname):
511                try:
512                        tag.set_property(propname, value)
513                except Exception:
514                        gajim.log.warn( "Error with prop: " + propname + " for tag: " + str(tag))
515               
516
517        def _parse_style_width(self, tag, value):
518                if value == 'auto':
519                        return
520                self._parse_length(value, False, False, 1, 1000, self.__length_tag_cb,
521                                                   tag, "width")
522        def _parse_style_height(self, tag, value):
523                if value == 'auto':
524                        return
525                self._parse_length(value, False, False, 1, 1000, self.__length_tag_cb,
526                                                   tag, "height")
527         
528       
529        # build a dictionary mapping styles to methods, for greater speed
530        __style_methods = dict()
531        for style in ('background-color', 'color', 'font-family', 'font-size',
532                                  'font-style', 'font-weight', 'margin-left', 'margin-right',
533                                  'text-align', 'text-decoration', 'white-space', 'display',
534                                  'width', 'height' ):
535                try:
536                        method = locals()['_parse_style_%s' % style.replace('-', '_')]
537                except KeyError:
538                        warnings.warn('Style attribute "%s" not yet implemented' % style)
539                else:
540                        __style_methods[style] = method
541        del style
542        # --
543
544        def _get_style_tags(self):
545                return [tag for tag in self.styles if tag is not None]
546
547        def _create_url(self, href, title, type_, id_):
548                '''Process a url tag.
549                '''
550                tag = self.textbuf.create_tag(id_)
551                if href and href[0] != '#':
552                        tag.href = href
553                        tag.type_ = type_ # to be used by the URL handler
554                        tag.connect('event', self.textview.html_hyperlink_handler, 'url', href)
555                        tag.set_property('foreground', '#0000ff')
556                        tag.set_property('underline', pango.UNDERLINE_SINGLE)
557                        tag.is_anchor = True
558                if title:
559                        tag.title = title
560                return tag
561
562        def _process_img(self, attrs):
563                '''Process a img tag.
564                '''
565                try:
566                        # Wait maximum 1s for connection
567                        socket.setdefaulttimeout(1)
568                        try: 
569                                f = urllib2.urlopen(attrs['src']) 
570                        except Exception, ex: 
571                                gajim.log.debug('Error loading image %s ' % attrs['src']  + str(ex))
572                                pixbuf = None 
573                                alt = attrs.get('alt', 'Broken image') 
574                        else: 
575                                # Wait 0.1s between each byte
576                                try: 
577                                        f.fp._sock.fp._sock.settimeout(0.5) 
578                                except Exception: 
579                                        pass 
580                        # Max image size = 2 MB (to try to prevent DoS)
581                        mem = ''
582                        deadline = time.time() + 3
583                        while True:
584                                if time.time() > deadline:
585                                        gajim.log.debug(str('Timeout loading image %s ' % \
586                                                attrs['src'] + ex))
587                                        mem = ''
588                                        alt = attrs.get('alt', '')
589                                        if alt:
590                                                alt += '\n'
591                                        alt += _('Timeout loading image')
592                                        break
593                                try:
594                                        temp = f.read(100)
595                                except socket.timeout, ex:
596                                        gajim.log.debug('Timeout loading image %s ' % attrs['src'] + \
597                                                str(ex))
598                                        mem = ''
599                                        alt = attrs.get('alt', '')
600                                        if alt:
601                                                alt += '\n'
602                                        alt += _('Timeout loading image')
603                                        break
604                                if temp:
605                                        mem += temp
606                                else:
607                                        break
608                                if len(mem) > 2*1024*1024:
609                                        alt = attrs.get('alt', '')
610                                        if alt:
611                                                alt += '\n'
612                                        alt += _('Image is too big')
613                                        break
614                        pixbuf = None
615                        if mem:
616                                # Caveat: GdkPixbuf is known not to be safe to load
617                                # images from network... this program is now potentially
618                                # hackable ;)
619                                loader = gtk.gdk.PixbufLoader()
620                                dims = [0,0]
621                                def height_cb(length):
622                                        dims[1] = length
623                                def width_cb(length):
624                                        dims[0] = length
625                                # process width and height attributes
626                                w = attrs.get('width')
627                                h = attrs.get('height')
628                                # override with width and height styles
629                                for attr, val in style_iter(attrs.get('style', '')):
630                                        if attr == 'width':
631                                                w = val
632                                        elif attr == 'height':
633                                                h = val
634                                if w:
635                                        self._parse_length(w, False,