root/branches/gajim_0.11/src/htmltextview.py

Revision 7940, 31.1 kB (checked in by asterix, 22 months ago)

merge diff from trunk

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