htmltextview.py 33.9 KB
Newer Older
roidelapluie's avatar
roidelapluie committed
1
# -*- coding:utf-8 -*-
roidelapluie's avatar
roidelapluie committed
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
## src/htmltextview.py
##
## Copyright (C) 2005 Gustavo J. A. M. Carneiro
## Copyright (C) 2006 Santiago Gala
## Copyright (C) 2006-2007 Jean-Marie Traissard <jim AT lapin.org>
## Copyright (C) 2006-2008 Yann Leboulanger <asterix AT lagaule.org>
## Copyright (C) 2007 Nikos Kouremenos <kourem AT gmail.com>
## Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
##                    Julien Pivotto <roidelapluie AT gmail.com>
##                    Stephan Erb <steve-e AT h3c.de>
##
## This file is part of Gajim.
##
## Gajim is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published
## by the Free Software Foundation; version 3 only.
##
## Gajim is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
23 24
##
## You should have received a copy of the GNU General Public License
roidelapluie's avatar
roidelapluie committed
25 26
## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
##
27

28
'''
29 30 31 32 33 34 35 36
A gtk.TextView-based renderer for XHTML-IM, as described in:
  http://www.jabber.org/jeps/jep-0071.html

Starting with the version posted by Gustavo Carneiro,
I (Santiago Gala) am trying to make it more compatible
with the markup that docutils generate, and also more
modular.

37
'''
38 39 40 41 42 43 44 45

import gobject
import pango
import gtk
import xml.sax, xml.sax.handler
import re
import warnings
from cStringIO import StringIO
46
import socket
Yann Leboulanger's avatar
Yann Leboulanger committed
47
import time
48 49 50
import urllib2
import operator

51 52
if __name__ == '__main__':
	from common import i18n
jimpp's avatar
jimpp committed
53
from common import gajim
54 55 56 57 58 59

import tooltips


__all__ = ['HtmlTextView']

60 61
whitespace_rx = re.compile('\\s+')
allwhitespace_rx = re.compile('^\\s*$')
62

63
# pixels = points * display_resolution
64 65 66
display_resolution = 0.3514598*(gtk.gdk.screen_height() /
					float(gtk.gdk.screen_height_mm()))

67
# embryo of CSS classes
68 69 70 71 72
classes = {
	#'system-message':';display: none',
	'problematic':';color: red',
}

73
# styles for elements
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
element_styles = {
		'u'			: ';text-decoration: underline',
		'em'		: ';font-style: oblique',
		'cite'		: '; background-color:rgb(170,190,250); font-style: oblique',
		'li'		: '; margin-left: 1em; margin-right: 10%',
		'strong'	: ';font-weight: bold',
		'pre'		: '; background-color:rgb(190,190,190); font-family: monospace; white-space: pre; margin-left: 1em; margin-right: 10%',
		'kbd'		: ';background-color:rgb(210,210,210);font-family: monospace',
		'blockquote': '; background-color:rgb(170,190,250); margin-left: 2em; margin-right: 10%',
		'dt'		: ';font-weight: bold; font-style: oblique',
		'dd'		: ';margin-left: 2em; font-style: oblique'
}
# no difference for the moment
element_styles['dfn'] = element_styles['em']
element_styles['var'] = element_styles['em']
# deprecated, legacy, presentational
element_styles['tt']  = element_styles['kbd']
element_styles['i']   = element_styles['em']
element_styles['b']   = element_styles['strong']

94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
'''
==========
  JEP-0071
==========

This Integration Set includes a subset of the modules defined for 
XHTML 1.0 but does not redefine any existing modules, nor 
does it define any new modules. Specifically, it includes the 
following modules only:

- Structure
- Text
  
  * Block
    
    phrasal
       addr, blockquote, pre
    Struc
       div,p
    Heading
       h1, h2, h3, h4, h5, h6
    
  * Inline
    
    phrasal
       abbr, acronym, cite, code, dfn, em, kbd, q, samp, strong, var
    structural
       br, span
  
- Hypertext (a)
- List (ul, ol, dl)
- Image (img)
- Style Attribute
     
Therefore XHTML-IM uses the following content models:

  Block.mix
            Block-like elements, e.g., paragraphs
  Flow.mix
            Any block or inline elements
  Inline.mix
            Character-level elements
  InlineNoAnchor.class
			Anchor element 
  InlinePre.mix
            Pre element

XHTML-IM also uses the following Attribute Groups:

Core.extra.attrib
	TBD
I18n.extra.attrib
	TBD
Common.extra
	style


...
#block level:
#Heading    h
#           ( pres           = h1 | h2 | h3 | h4 | h5 | h6 )
#Block      ( phrasal        = address | blockquote | pre )
#NOT           ( presentational = hr )
#           ( structural     = div | p )
#other:     section
#Inline     ( phrasal        = abbr | acronym | cite | code | dfn | em | kbd | q | samp | strong | var )
#NOT        ( presentational =  b  | big | i | small | sub | sup | tt )
#           ( structural     =  br | span )
#Param/Legacy    param, font, basefont, center, s, strike, u, dir, menu, isindex
163
#
164
'''
165 166 167 168 169

BLOCK_HEAD = set(( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', ))
BLOCK_PHRASAL = set(( 'address', 'blockquote', 'pre', ))
BLOCK_PRES = set(( 'hr', )) #not in xhtml-im
BLOCK_STRUCT = set(( 'div', 'p', ))
170
BLOCK_HACKS = set(( 'table', 'tr' )) # at the very least, they will start line ;)
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
BLOCK = BLOCK_HEAD.union(BLOCK_PHRASAL).union(BLOCK_STRUCT).union(BLOCK_PRES).union(BLOCK_HACKS)

INLINE_PHRASAL = set('abbr, acronym, cite, code, dfn, em, kbd, q, samp, strong, var'.split(', '))
INLINE_PRES = set('b, i, u, tt'.split(', ')) #not in xhtml-im
INLINE_STRUCT = set('br, span'.split(', '))
INLINE = INLINE_PHRASAL.union(INLINE_PRES).union(INLINE_STRUCT)

LIST_ELEMS = set( 'dl, ol, ul'.split(', '))

for name in BLOCK_HEAD:
	num = eval(name[1])
	size = (num-1) // 2
	weigth = (num - 1) % 2
	element_styles[name] = '; font-size: %s; %s' % ( ('large', 'medium', 'small')[size],
													('font-weight: bold', 'font-style: oblique')[weigth],
											  )


def build_patterns(view, config, interface):
190 191
	# extra, rst does not mark _underline_ or /it/ up
	# actually <b>, <i> or <u> are not in the JEP-0071, but are seen in the wild
192 193 194
	basic_pattern = r'(?<!\w|\<|/|:)' r'/[^\s/]' r'([^/]*[^\s/])?' r'/(?!\w|/|:)|'\
					r'(?<!\w)' r'_[^\s_]' r'([^_]*[^\s_])?' r'_(?!\w)'
	view.basic_pattern_re = re.compile(basic_pattern)
195
	# emoticons
196
	emoticons_pattern = ''
197 198
	if config.get('emoticons_theme'):
		emoticons_pattern = gajim.interface.emot_only
199

200
	view.emot_pattern_re = re.compile(emoticons_pattern, re.IGNORECASE)
201 202 203 204 205 206 207 208
	# because emoticons match later (in the string) they need to be after
	# basic matches that may occur earlier
	emot_and_basic_pattern = basic_pattern + emoticons_pattern
	view.emot_and_basic_re = re.compile(emot_and_basic_pattern, re.IGNORECASE)


def _parse_css_color(color):
	'''_parse_css_color(css_color) -> gtk.gdk.Color'''
209
	if color.startswith('rgb(') and color.endswith(')'):
210 211 212 213
		r, g, b = [int(c)*257 for c in color[4:-1].split(',')]
		return gtk.gdk.Color(r, g, b)
	else:
		return gtk.gdk.color_parse(color)
214 215 216

def style_iter(style):
	return (map(lambda x:x.strip(),item.split(':', 1)) for item in style.split(';') if len(item.strip()))
217 218 219
	

class HtmlHandler(xml.sax.handler.ContentHandler):
220 221 222 223 224
	"""A handler to display html to a gtk textview.

	It keeps a stack of "style spans" (start/end element pairs)
	and a stack of list counters, for nested lists.
	"""
225 226 227 228 229 230 231 232 233 234 235 236 237 238
	def __init__(self, textview, startiter):
		xml.sax.handler.ContentHandler.__init__(self)
		self.textbuf = textview.get_buffer()
		self.textview = textview
		self.iter = startiter
		self.text = ''
		self.starting=True
		self.preserve = False
		self.styles = [] # a gtk.TextTag or None, for each span level
		self.list_counters = [] # stack (top at head) of list
								# counters, or None for unordered list

	def _parse_style_color(self, tag, value):
		color = _parse_css_color(value)
239
		tag.set_property('foreground-gdk', color)
240 241 242

	def _parse_style_background_color(self, tag, value):
		color = _parse_css_color(value)
243 244
		tag.set_property('background-gdk', color)
		tag.set_property('paragraph-background-gdk', color)
245 246


247
	#FIXME: when we migrate to 2.10 rm this
248 249 250 251 252 253 254 255 256 257 258
	if gtk.gtk_version >= (2, 8, 5) or gobject.pygtk_version >= (2, 8, 1):

		def _get_current_attributes(self):
			attrs = self.textview.get_default_attributes()
			self.iter.backward_char()
			self.iter.get_attributes(attrs)
			self.iter.forward_char()
			return attrs
		
	else:
		
259
		# Workaround http://bugzilla.gnome.org/show_bug.cgi?id=317455
260 261 262
		def _get_current_style_attr(self, propname, comb_oper=None):
			tags = [tag for tag in self.styles if tag is not None]
			tags.reverse()
263
			is_set_name = propname + '-set'
264 265 266 267 268 269 270 271 272 273 274 275
			value = None
			for tag in tags:
				if tag.get_property(is_set_name):
					if value is None:
						value = tag.get_property(propname)
						if comb_oper is None:
							return value
					else:
						value = comb_oper(value, tag.get_property(propname))
			return value

		class _FakeAttrs(object):
276
			__slots__ = ('font', 'font_scale')
277 278 279

		def _get_current_attributes(self):
			attrs = self._FakeAttrs()
280
			attrs.font_scale = self._get_current_style_attr('scale',
281 282 283
															operator.mul)
			if attrs.font_scale is None:
				attrs.font_scale = 1.0
284
			attrs.font = self._get_current_style_attr('font-desc')
285 286 287 288 289 290 291 292 293
			if attrs.font is None:
				attrs.font = self.textview.style.font_desc
			return attrs


	def __parse_length_frac_size_allocate(self, textview, allocation,
										  frac, callback, args):
		callback(allocation.width*frac, *args)

294
	def _parse_length(self, value, font_relative, block_relative, minl, maxl, callback, *args):
295 296 297
		'''Parse/calc length, converting to pixels, calls callback(length, *args)
		when the length is first computed or changes'''
		if value.endswith('%'):
298 299 300 301 302
			val = float(value[:-1])
			sign = cmp(val,0)
			# limits: 1% to 500%
			val = sign*max(1,min(abs(val),500))
			frac = val/100 
303 304 305 306
			if font_relative:
				attrs = self._get_current_attributes()
				font_size = attrs.font.get_size() / pango.SCALE
				callback(frac*display_resolution*font_size, *args)
307
			elif block_relative:
308 309
				# CSS says 'Percentage values: refer to width of the closest
				#           block-level ancestor'
310 311
				# This is difficult/impossible to implement, so we use
				# textview width instead; a reasonable approximation..
312 313 314
				alloc = self.textview.get_allocation()
				self.__parse_length_frac_size_allocate(self.textview, alloc,
													   frac, callback, args)
315
				self.textview.connect('size-allocate',
316 317
									  self.__parse_length_frac_size_allocate,
									  frac, callback, args)
318 319 320
			else:
				callback(frac, *args)
			return
321

322 323 324 325 326
		def get_val():
			val = float(value[:-2])
			sign = cmp(val,0)
			# validate length
			return sign*max(minl,min(abs(val*display_resolution),maxl))
327
		if value.endswith('pt'): # points
328
			callback(get_val()*display_resolution, *args)
329

330
		elif value.endswith('em'): # ems, the width of the element's font
331 332
			attrs = self._get_current_attributes()
			font_size = attrs.font.get_size() / pango.SCALE
333
			callback(get_val()*display_resolution*font_size, *args)
334 335

		elif value.endswith('ex'): # x-height, ~ the height of the letter 'x'
336 337
			# FIXME: figure out how to calculate this correctly
			#        for now 'em' size is used as approximation
338 339
			attrs = self._get_current_attributes()
			font_size = attrs.font.get_size() / pango.SCALE
340
			callback(get_val()*display_resolution*font_size, *args)
341 342

		elif value.endswith('px'): # pixels
343
			callback(get_val(), *args)
344 345

		else:
346 347 348 349 350 351 352
			try:
				# TODO: isn't "no units" interpreted as pixels?
				val = int(value)
				sign = cmp(val,0)
				# validate length
				val = sign*max(minl,min(abs(val),maxl))
				callback(val, *args)
353
			except Exception:
354
				warnings.warn('Unable to parse length value "%s"' % value)
355 356
		
	def __parse_font_size_cb(length, tag):
357
		tag.set_property('size-points', length/display_resolution)
358 359 360 361 362
	__parse_font_size_cb = staticmethod(__parse_font_size_cb)

	def _parse_style_display(self, tag, value):
		if value == 'none':
			tag.set_property('invisible','true')
363
		# FIXME: display: block, inline
364 365 366 367

	def _parse_style_font_size(self, tag, value):
		try:
			scale = {
368 369 370 371 372 373 374
				'xx-small': pango.SCALE_XX_SMALL,
				'x-small': pango.SCALE_X_SMALL,
				'small': pango.SCALE_SMALL,
				'medium': pango.SCALE_MEDIUM,
				'large': pango.SCALE_LARGE,
				'x-large': pango.SCALE_X_LARGE,
				'xx-large': pango.SCALE_XX_LARGE,
375 376 377 378 379
				} [value]
		except KeyError:
			pass
		else:
			attrs = self._get_current_attributes()
380
			tag.set_property('scale', scale / attrs.font_scale)
381 382
			return
		if value == 'smaller':
383
			tag.set_property('scale', pango.SCALE_SMALL)
384 385
			return
		if value == 'larger':
386
			tag.set_property('scale', pango.SCALE_LARGE)
387
			return
388 389
		# font relative (5 ~ 4pt, 110 ~ 72pt)
		self._parse_length(value, True, False, 5, 110, self.__parse_font_size_cb, tag)
390 391 392 393

	def _parse_style_font_style(self, tag, value):
		try:
			style = {
394 395 396
				'normal': pango.STYLE_NORMAL,
				'italic': pango.STYLE_ITALIC,
				'oblique': pango.STYLE_OBLIQUE,
397 398
				} [value]
		except KeyError:
399
			warnings.warn('unknown font-style %s' % value)
400
		else:
401
			tag.set_property('style', style)
402 403 404 405 406 407 408 409 410

	def __frac_length_tag_cb(self,length, tag, propname):
		styles = self._get_style_tags()
		if styles:
			length += styles[-1].get_property(propname)
		tag.set_property(propname, length)
	#__frac_length_tag_cb = staticmethod(__frac_length_tag_cb)
		
	def _parse_style_margin_left(self, tag, value):
411 412
		# block relative
		self._parse_length(value, False, True, 1, 1000, self.__frac_length_tag_cb,
413
						   tag, 'left-margin')
414 415

	def _parse_style_margin_right(self, tag, value):
416 417
		# block relative
		self._parse_length(value, False, True, 1, 1000, self.__frac_length_tag_cb,
418
						   tag, 'right-margin')
419 420

	def _parse_style_font_weight(self, tag, value):
421
		# TODO: missing 'bolder' and 'lighter'
422 423 424 425 426 427 428 429 430 431 432 433 434 435 436
		try:
			weight = {
				'100': pango.WEIGHT_ULTRALIGHT,
				'200': pango.WEIGHT_ULTRALIGHT,
				'300': pango.WEIGHT_LIGHT,
				'400': pango.WEIGHT_NORMAL,
				'500': pango.WEIGHT_NORMAL,
				'600': pango.WEIGHT_BOLD,
				'700': pango.WEIGHT_BOLD,
				'800': pango.WEIGHT_ULTRABOLD,
				'900': pango.WEIGHT_HEAVY,
				'normal': pango.WEIGHT_NORMAL,
				'bold': pango.WEIGHT_BOLD,
				} [value]
		except KeyError:
437
			warnings.warn('unknown font-style %s' % value)
438
		else:
439
			tag.set_property('weight', weight)
440 441

	def _parse_style_font_family(self, tag, value):
442
		tag.set_property('family', value)
443 444 445 446 447 448 449 450 451 452

	def _parse_style_text_align(self, tag, value):
		try:
			align = {
				'left': gtk.JUSTIFY_LEFT,
				'right': gtk.JUSTIFY_RIGHT,
				'center': gtk.JUSTIFY_CENTER,
				'justify': gtk.JUSTIFY_FILL,
				} [value]
		except KeyError:
453
			warnings.warn('Invalid text-align:%s requested' % value)
454
		else:
455
			tag.set_property('justification', align)
456 457
	
	def _parse_style_text_decoration(self, tag, value):
458 459
		values = value.split(' ')
		if 'none' in values:
460 461
			tag.set_property('underline', pango.UNDERLINE_NONE)
			tag.set_property('strikethrough', False)
462
		if 'underline' in values:
463
			tag.set_property('underline', pango.UNDERLINE_SINGLE)
464
		else:
465
			tag.set_property('underline', pango.UNDERLINE_NONE)
466
		if 'line-through' in values:
467
			tag.set_property('strikethrough', True)
468
		else:
469 470 471 472 473
			tag.set_property('strikethrough', False)
		if 'blink' in values:
			warnings.warn('text-decoration:blink not implemented')
		if 'overline' in values:
			warnings.warn('text-decoration:overline not implemented')
474 475 476
	
	def _parse_style_white_space(self, tag, value):
		if value == 'pre':
477
			tag.set_property('wrap_mode', gtk.WRAP_NONE)
478
		elif value == 'normal':
479
			tag.set_property('wrap_mode', gtk.WRAP_WORD)
480
		elif value == 'nowrap':
481
			tag.set_property('wrap_mode', gtk.WRAP_NONE)
482 483 484 485

	def __length_tag_cb(self, value, tag, propname):
		try:
			tag.set_property(propname, value)
486
		except Exception:
487 488 489 490 491 492 493 494 495 496 497 498 499
			gajim.log.warn( "Error with prop: " + propname + " for tag: " + str(tag))
		

	def _parse_style_width(self, tag, value):
		if value == 'auto':
			return
		self._parse_length(value, False, False, 1, 1000, self.__length_tag_cb,
						   tag, "width")
	def _parse_style_height(self, tag, value):
		if value == 'auto':
			return
		self._parse_length(value, False, False, 1, 1000, self.__length_tag_cb,
						   tag, "height")
500 501
	 
	
502
	# build a dictionary mapping styles to methods, for greater speed
503
	__style_methods = dict()
504
	for style in ('background-color', 'color', 'font-family', 'font-size',
505
				  'font-style', 'font-weight', 'margin-left', 'margin-right',
506
				  'text-align', 'text-decoration', 'white-space', 'display',
507
				  'width', 'height' ):
508
		try:
509
			method = locals()['_parse_style_%s' % style.replace('-', '_')]
510
		except KeyError:
nkour's avatar
nkour committed
511
			warnings.warn('Style attribute "%s" not yet implemented' % style)
512 513 514
		else:
			__style_methods[style] = method
	del style
515
	# --
516 517 518 519 520

	def _get_style_tags(self):
		return [tag for tag in self.styles if tag is not None]

	def _create_url(self, href, title, type_, id_):
521 522
		'''Process a url tag.
		'''
523 524 525 526 527 528 529 530 531 532 533 534
		tag = self.textbuf.create_tag(id_)
		if href and href[0] != '#':
			tag.href = href
			tag.type_ = type_ # to be used by the URL handler
			tag.connect('event', self.textview.html_hyperlink_handler, 'url', href)
			tag.set_property('foreground', '#0000ff')
			tag.set_property('underline', pango.UNDERLINE_SINGLE)
			tag.is_anchor = True
		if title:
			tag.title = title
		return tag

535 536 537 538 539 540 541 542 543
	def _process_img(self, attrs):
		'''Process a img tag.
		'''
		try:
			# Wait maximum 1s for connection 
			socket.setdefaulttimeout(1)
			try: 
				f = urllib2.urlopen(attrs['src']) 
			except Exception, ex: 
steve-e's avatar
steve-e committed
544
				gajim.log.debug('Error loading image %s ' % attrs['src']  + str(ex))
545 546 547 548 549 550
				pixbuf = None 
				alt = attrs.get('alt', 'Broken image') 
			else: 
				# Wait 0.1s between each byte 
				try: 
					f.fp._sock.fp._sock.settimeout(0.5) 
551
				except Exception: 
552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616
					pass 
			# Max image size = 2 MB (to try to prevent DoS)
			mem = ''
			deadline = time.time() + 3
			while True:
				if time.time() > deadline:
					gajim.log.debug(str('Timeout loading image %s ' % \
						attrs['src'] + ex))
					mem = ''
					alt = attrs.get('alt', '')
					if alt:
						alt += '\n'
					alt += _('Timeout loading image')
					break
				try:
					temp = f.read(100)
				except socket.timeout, ex:
					gajim.log.debug('Timeout loading image %s ' % attrs['src'] + \
						str(ex))
					mem = ''
					alt = attrs.get('alt', '')
					if alt:
						alt += '\n'
					alt += _('Timeout loading image')
					break
				if temp:
					mem += temp
				else:
					break
				if len(mem) > 2*1024*1024:
					alt = attrs.get('alt', '')
					if alt:
						alt += '\n'
					alt += _('Image is too big')
					break
			pixbuf = None
			if mem:
				# Caveat: GdkPixbuf is known not to be safe to load
				# images from network... this program is now potentially
				# hackable ;)
				loader = gtk.gdk.PixbufLoader()
				dims = [0,0]
				def height_cb(length):
					dims[1] = length
				def width_cb(length):
					dims[0] = length
				# process width and height attributes
				w = attrs.get('width')
				h = attrs.get('height')
				# override with width and height styles
				for attr, val in style_iter(attrs.get('style', '')):
					if attr == 'width':
						w = val
					elif attr == 'height':
						h = val
				if w:
					self._parse_length(w, False, False, 1, 1000, width_cb)
				if h:
					self._parse_length(h, False, False, 1, 1000, height_cb)
				def set_size(pixbuf, w, h, dims):
					'''FIXME: floats should be relative to the whole
					textview, and resize with it. This needs new
					pifbufs for every resize, gtk.gdk.Pixbuf.scale_simple
					or similar.
					'''
617
					if isinstance(dims[0], float):
618 619 620
						dims[0] = int(dims[0]*w)
					elif not dims[0]:
						dims[0] = w
621
					if isinstance(dims[1], float):
622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650
						dims[1] = int(dims[1]*h)
					if not dims[1]:
						dims[1] = h
					loader.set_size(*dims)
				if w or h:
					loader.connect('size-prepared', set_size, dims)
				loader.write(mem)
				loader.close()
				pixbuf = loader.get_pixbuf()
				alt = attrs.get('alt', '')
			if pixbuf is not None:
				tags = self._get_style_tags()
				if tags:
					tmpmark = self.textbuf.create_mark(None, self.iter, True)
				self.textbuf.insert_pixbuf(self.iter, pixbuf)
				self.starting = False
				if tags:
					start = self.textbuf.get_iter_at_mark(tmpmark)
					for tag in tags:
						self.textbuf.apply_tag(tag, start, self.iter)
					self.textbuf.delete_mark(tmpmark)
			else:
				self._insert_text('[IMG: %s]' % alt)
		except Exception, ex:
			gajim.log.error('Error loading image ' + str(ex))
			pixbuf = None
			alt = attrs.get('alt', 'Broken image')
			try:
				loader.close()
651
			except Exception:
652 653
				pass
		return pixbuf
654 655 656 657 658 659 660 661 662

	def _begin_span(self, style, tag=None, id_=None):
		if style is None:
			self.styles.append(tag)
			return None
		if tag is None:
			if id_:
				tag = self.textbuf.create_tag(id_)
			else:
nkour's avatar
nkour committed
663
				tag = self.textbuf.create_tag() # we create anonymous tag
664 665 666
		for attr, val in style_iter(style):
			attr = attr.lower()
			val = val
667 668 669
			try:
				method = self.__style_methods[attr]
			except KeyError:
nkour's avatar
nkour committed
670
				warnings.warn('Style attribute "%s" requested '
671
							  'but not yet implemented' % attr)
672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701
			else:
				method(self, tag, val)
		self.styles.append(tag)

	def _end_span(self):
		self.styles.pop()

	def _jump_line(self):
		self.textbuf.insert_with_tags_by_name(self.iter, '\n', 'eol')
		self.starting = True

	def _insert_text(self, text):
		if self.starting and text != '\n':
			self.starting = (text[-1] == '\n')
		tags = self._get_style_tags()
		if tags:
			self.textbuf.insert_with_tags(self.iter, text, *tags)
		else:
			self.textbuf.insert(self.iter, text)

	def _starts_line(self):
		return self.starting or self.iter.starts_line()
		
	def _flush_text(self):
		if not self.text: return
		text, self.text = self.text, ''
		if not self.preserve:
			text = text.replace('\n', ' ')
			self.handle_specials(whitespace_rx.sub(' ', text))
		else:
702
			self._insert_text(text.strip('\n'))
703

704
	def _anchor_event(self, tag, textview, event, iter_, href, type_):
705
		if event.type == gtk.gdk.BUTTON_PRESS:
706
			self.textview.emit('url-clicked', href, type_)
707 708 709 710 711 712
			return True
		return False

	def handle_specials(self, text):
		index = 0
		se = self.textview.config.get('show_ascii_formatting_chars')
713
		af = gajim.config.get('ascii_formatting')
714
		if self.textview.config.get('emoticons_theme'):
715 716 717 718 719
			if af:
				iterator = self.textview.emot_and_basic_re.finditer(text)
			else:
				iterator = self.textview.emot_pattern_re.finditer(text)
		elif af:
720
			iterator = self.textview.basic_pattern_re.finditer(text)
721 722
		else:
			iterator = []
723 724 725 726 727 728 729 730 731 732 733 734 735 736 737
		for match in iterator:
			start, end = match.span()
			special_text = text[start:end]
			if start != 0:
				self._insert_text(text[index:start])
			index = end # update index
			#emoticons
			possible_emot_ascii_caps = special_text.upper() # emoticons keys are CAPS
			if self.textview.config.get('emoticons_theme') and \
					possible_emot_ascii_caps in self.textview.interface.emoticons.keys():
				#it's an emoticon
				emot_ascii = possible_emot_ascii_caps
				anchor = self.textbuf.create_child_anchor(self.iter)
				img = gtk.Image()
				img.set_from_file(self.textview.interface.emoticons[emot_ascii])
738
				img.show()
739 740
				# TODO: add alt/tooltip with the special_text (a11y) 
				self.textview.add_child_at_anchor(img, anchor)
741
			elif af:
742 743 744 745
				# now print it
				if special_text.startswith('/'): # it's explicit italics
					self.startElement('i', {})
				elif special_text.startswith('_'): # it's explicit underline
746
					self.startElement('u', {})
747 748 749 750 751 752 753
				if se: self._insert_text(special_text[0])
				self.handle_specials(special_text[1:-1])
				if se: self._insert_text(special_text[0])
				if special_text.startswith('_'): # it's explicit underline
					self.endElement('u')
				if special_text.startswith('/'): # it's explicit italics
					self.endElement('i')
754 755
		if index < len(text):
			self._insert_text(text[index:])
756 757 758 759 760 761
		
	def characters(self, content):
		if self.preserve:
			self.text += content
			return
		if allwhitespace_rx.match(content) is not None and self._starts_line():
762
			self.text += ' '
763 764
			return
		self.text += content
765
		self.starting = False
766 767 768 769 770


	def startElement(self, name, attrs):
		self._flush_text()
		klass = [i for i in attrs.get('class',' ').split(' ') if i]
771
		style = ''
772 773 774 775 776 777 778 779 780 781 782 783
		#Add styles defined for classes
		for k in klass:
			if k  in classes:
				style += classes[k]

		tag = None
		#FIXME: if we want to use id, it needs to be unique across
		# the whole textview, so we need to add something like the
		# message-id to it.
		#id_ = attrs.get('id',None) 
		id_ = None
		if name == 'a':
784
			#TODO: accesskey, charset, hreflang, rel, rev, tabindex, type
785 786 787
			href = attrs.get('href', None)
			if not href:
				href = attrs.get('HREF', None)
788
			# Gaim sends HREF instead of href
789 790 791 792 793 794 795 796 797 798 799
			title = attrs.get('title', attrs.get('rel',href))
			type_ = attrs.get('type', None)
			tag = self._create_url(href, title, type_, id_)
		elif name == 'blockquote':
			cite = attrs.get('cite', None)
			if cite:
				tag = self.textbuf.create_tag(id_)
				tag.title = title
				tag.is_anchor = True
		elif name in LIST_ELEMS:
			style += ';margin-left: 2em'
800 801
		elif name == 'img':
			tag = self._process_img(attrs)
802 803
		if name in element_styles:
			style += element_styles[name]
804 805 806
		# so that explicit styles override implicit ones, 
		# we add the attribute last
		style += ";"+attrs.get('style','')
807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834
		if style == '':
			style = None        
		self._begin_span(style, tag, id_)

		if name == 'br':
			pass # handled in endElement
		elif name == 'hr':
			pass # handled in endElement
		elif name in BLOCK:
			if not self._starts_line():
				self._jump_line()
			if name == 'pre':
				self.preserve = True
		elif name == 'span':
			pass
		elif name in ('dl', 'ul'):
			if not self._starts_line():
				self._jump_line()
			self.list_counters.append(None)
		elif name == 'ol':
			if not self._starts_line():
				self._jump_line()
			self.list_counters.append(0)
		elif name == 'li':
			if self.list_counters[-1] is None:
				li_head = unichr(0x2022)
			else:
				self.list_counters[-1] += 1
835
				li_head = '%i.' % self.list_counters[-1]
836 837 838 839 840 841 842 843
			self.text = ' '*len(self.list_counters)*4 + li_head + ' '
			self._flush_text()
			self.starting = True
		elif name == 'dd':
			self._jump_line()
		elif name == 'dt':
			if not self.starting:
				self._jump_line()
844
		elif name in ('a', 'img', 'body', 'html'):
845 846 847 848
			pass
		elif name in INLINE:
			pass
		else:
nkour's avatar
nkour committed
849
			warnings.warn('Unhandled element "%s"' % name)
850 851 852 853 854 855 856 857 858 859 860

	def endElement(self, name):
		endPreserving = False
		newLine = False
		if name == 'br':
			newLine = True
		elif name == 'hr':
			#FIXME: plenty of unused attributes (width, height,...) :)
			self._jump_line()
			try:
				self.textbuf.insert_pixbuf(self.iter, self.textview.focus_out_line_pixbuf)
861
				#self._insert_text(u'\u2550'*40)
862 863
				self._jump_line()
			except Exception, e:
864
				gajim.log.debug(str('Error in hr'+e))
865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882
		elif name in LIST_ELEMS:
			self.list_counters.pop()
		elif name == 'li':
			newLine = True
		elif name == 'img':
			pass
		elif name == 'body' or name == 'html':
			pass
		elif name == 'a':
			pass
		elif name in INLINE:
			pass
		elif name in ('dd', 'dt', ):
			pass
		elif name in BLOCK:
			if name == 'pre':
				endPreserving = True
		else:
nkour's avatar
nkour committed
883
			warnings.warn("Unhandled element '%s'" % name)
884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899
		self._flush_text()
		if endPreserving:
			self.preserve = False
		if newLine:
			self._jump_line()
		self._end_span()
		#if not self._starts_line():
		#    self.text = ' '

class HtmlTextView(gtk.TextView):
	
	def __init__(self):
		gobject.GObject.__init__(self)
		self.set_wrap_mode(gtk.WRAP_CHAR)
		self.set_editable(False)
		self._changed_cursor = False
900
		self.connect('destroy', self.__destroy_event)
901 902 903
		self.connect('motion-notify-event', self.__motion_notify_event)
		self.connect('leave-notify-event', self.__leave_event)
		self.connect('enter-notify-event', self.__motion_notify_event)
904 905 906 907 908 909 910
		self.get_buffer().create_tag('eol', scale = pango.SCALE_XX_SMALL)
		self.tooltip = tooltips.BaseTooltip()
		self.config = gajim.config
		self.interface = gajim.interface
		# end big hack
		build_patterns(self,gajim.config,gajim.interface)

911 912 913 914
	def __destroy_event(self, widget):
		if self.tooltip.timeout != 0:
			self.tooltip.hide_tooltip()

915 916 917 918 919 920 921 922 923
	def __leave_event(self, widget, event):
		if self._changed_cursor:
			window = widget.get_window(gtk.TEXT_WINDOW_TEXT)
			window.set_cursor(gtk.gdk.Cursor(gtk.gdk.XTERM))
			self._changed_cursor = False

	def show_tooltip(self, tag):
		if not self.tooltip.win:
			# check if the current pointer is still over the line
Yann Leboulanger's avatar
Yann Leboulanger committed
924 925 926 927 928 929 930 931 932 933
			x, y, _ = self.window.get_pointer()
			x, y = self.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, x, y)
			tags = self.get_iter_at_location(x, y).get_tags()
			is_over_anchor = False
			for tag_ in tags:
				if getattr(tag_, 'is_anchor', False):
					is_over_anchor = True
					break
			if not is_over_anchor:
				return
934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956
			text = getattr(tag, 'title', False)
			if text:
				pointer = self.get_pointer()
				position = self.window.get_origin()
				self.tooltip.show_tooltip(text, 8, position[1] + pointer[1])

	def __motion_notify_event(self, widget, event):
		x, y, _ = widget.window.get_pointer()
		x, y = widget.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, x, y)
		tags = widget.get_iter_at_location(x, y).get_tags()
		is_over_anchor = False
		for tag in tags:
			if getattr(tag, 'is_anchor', False):
				is_over_anchor = True
				break
		if self.tooltip.timeout != 0:
			# Check if we should hide the line tooltip
			if not is_over_anchor:
				self.tooltip.hide_tooltip()
		if not self._changed_cursor and is_over_anchor:
			window = widget.get_window(gtk.TEXT_WINDOW_TEXT)
			window.set_cursor(gtk.gdk.Cursor(gtk.gdk.HAND2))
			self._changed_cursor = True
Yann Leboulanger's avatar
Yann Leboulanger committed
957
			self.tooltip.timeout = gobject.timeout_add(500, self.show_tooltip, tag)
958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973
		elif self._changed_cursor and not is_over_anchor:
			window = widget.get_window(gtk.TEXT_WINDOW_TEXT)
			window.set_cursor(gtk.gdk.Cursor(gtk.gdk.XTERM))
			self._changed_cursor = False
		return False

	def display_html(self, html):
		buffer = self.get_buffer()
		eob = buffer.get_end_iter()
		## this works too if libxml2 is not available
		# parser = xml.sax.make_parser(['drv_libxml2'])
		# parser.setFeature(xml.sax.handler.feature_validation, True)
		parser = xml.sax.make_parser()
		parser.setContentHandler(HtmlHandler(self, eob))
		parser.parse(StringIO(html))
		
974
		# too much space after :)
975
		#if not eob.starts_line():
976
		#    buffer.insert(eob, '\n')
977 978


979

980 981 982
change_cursor = None

if __name__ == '__main__':
983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998
	import os
	from common import gajim

	class log(object):

		def debug(self, text):
			print "debug:", text
		def warn(self, text):
			print "warn;", text
		def error(self,text):
			print "error;", text

	gajim.log=log()

	if gajim.config.get('emoticons_theme'):
		print "emoticons"
999 1000 1001

	htmlview = HtmlTextView()

1002 1003 1004 1005 1006
	path_to_file = os.path.join(gajim.DATA_DIR, 'pixmaps', 'muc_separator.png')
	# use this for hr
	htmlview.focus_out_line_pixbuf =  gtk.gdk.pixbuf_new_from_file(path_to_file)


1007 1008 1009 1010
	tooltip = tooltips.BaseTooltip()
	def on_textview_motion_notify_event(widget, event):
		'''change the cursor to a hand when we are over a mail or an url'''
		global change_cursor
1011
		pointer_x, pointer_y, spam = htmlview.window.get_pointer()
1012 1013 1014 1015 1016 1017 1018 1019
		x, y = htmlview.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, pointer_x,
								   pointer_y)
		tags = htmlview.get_iter_at_location(x, y).get_tags()
		if change_cursor:
			htmlview.get_window(gtk.TEXT_WINDOW_TEXT).set_cursor(
					 gtk.gdk.Cursor(gtk.gdk.XTERM))
			change_cursor = None
		tag_table = htmlview.get_buffer().get_tag_table()
1020
		over_line = False
1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043
		for tag in tags:
			try:
				if tag.is_anchor:
					htmlview.get_window(gtk.TEXT_WINDOW_TEXT).set_cursor(
										gtk.gdk.Cursor(gtk.gdk.HAND2))
					change_cursor = tag
				elif tag == tag_table.lookup('focus-out-line'):
					over_line = True
			except: pass

		#if line_tooltip.timeout != 0:
			# Check if we should hide the line tooltip
		#	if not over_line:
		#		line_tooltip.hide_tooltip()
		#if over_line and not line_tooltip.win:
		#	line_tooltip.timeout = gobject.timeout_add(500,
		#		show_line_tooltip)
		#	htmlview.get_window(gtk.TEXT_WINDOW_TEXT).set_cursor(
		#		gtk.gdk.Cursor(gtk.gdk.LEFT_PTR))
		#	change_cursor = tag

	htmlview.connect('motion_notify_event', on_textview_motion_notify_event)

1044
	def handler(texttag, widget, event, iter_, kind, href):
1045 1046 1047 1048 1049 1050 1051 1052 1053
		if event.type == gtk.gdk.BUTTON_PRESS:
			print href

	htmlview.html_hyperlink_handler = handler

	htmlview.display_html('<div><span style="color: red; text-decoration:underline">Hello</span><br/>\n'
						  '  <img src="http://images.slashdot.org/topics/topicsoftware.gif"/><br/>\n'
						  '  <span style="font-size: 500%; font-family: serif">World</span>\n'
						  '</div>\n')
1054 1055
	htmlview.display_html('<hr />')
	htmlview.display_html('''
1056 1057 1058 1059 1060
	  <p style='font-size:large'>
		<span style='font-style: italic'>O<span style='font-size:larger'>M</span>G</span>, 
		I&apos;m <span style='color:green'>green</span>
		with <span style='font-weight: bold'>envy</span>!
	  </p>
1061 1062 1063
		''')
	htmlview.display_html('<hr />')
	htmlview.display_html('''
1064 1065 1066 1067 1068 1069
	<body xmlns='http://www.w3.org/1999/xhtml'>
	  <p>As Emerson said in his essay <span style='font-style: italic; background-color:cyan'>Self-Reliance</span>:</p>
	  <p style='margin-left: 5px; margin-right: 2%'>
		&quot;A foolish consistency is the hobgoblin of little minds.&quot;
	  </p>
	</body>
1070 1071 1072
		''')
	htmlview.display_html('<hr />')
	htmlview.display_html('''
1073 1074 1075
	<body xmlns='http://www.w3.org/1999/xhtml'>
	  <p style='text-align:center'>Hey, are you licensed to <a href='http://www.jabber.org/'>Jabber</a>?</p>
	  <p style='text-align:right'><img src='http://www.jabber.org/images/psa-license.jpg'
1076 1077 1078
			  alt='A License to Jabber' 
			  width='50%' height='50%' 
			  /></p>
1079
	</body>
1080 1081 1082
		''')
	htmlview.display_html('<hr />')
	htmlview.display_html('''
1083 1084 1085 1086 1087 1088 1089 1090 1091
	<body xmlns='http://www.w3.org/1999/xhtml'>
	  <ul style='background-color:rgb(120,140,100)'>
	   <li> One </li>
	   <li> Two </li>
	   <li> Three </li>
	  </ul><hr /><pre style="background-color:rgb(120,120,120)">def fac(n):
  def faciter(n,acc): 
	if n==0: return acc
	return faciter(n-1, acc*n)
1092
  if n&lt;0: raise ValueError('Must be non-negative')
1093 1094
  return faciter(n,1)</pre>
	</body>
1095 1096 1097
		''')
	htmlview.display_html('<hr />')
	htmlview.display_html('''
1098 1099 1100 1101 1102
	<body xmlns='http://www.w3.org/1999/xhtml'>
	 <ol style='background-color:rgb(120,140,100)'>
	   <li> One </li>
	   <li> Two is nested: <ul style='background-color:rgb(200,200,100)'>
			 <li> One </li>
1103 1104 1105
			 <li style='font-size:50%'> Two </li>
			 <li style='font-size:200%'> Three </li>
			 <li style='font-size:9999pt'> Four </li>
1106 1107 1108
			</ul></li>
	   <li> Three </li></ol>
	</body>
1109
		''')
1110 1111
	htmlview.show()
	sw = gtk.ScrolledWindow()
1112 1113 1114
	sw.set_property('hscrollbar-policy', gtk.POLICY_AUTOMATIC)
	sw.set_property('vscrollbar-policy', gtk.POLICY_AUTOMATIC)
	sw.set_property('border-width', 0)
1115 1116 1117 1118 1119 1120 1121 1122 1123 1124
	sw.add(htmlview)
	sw.show()
	frame = gtk.Frame()
	frame.set_shadow_type(gtk.SHADOW_IN)
	frame.show()
	frame.add(sw)
	w = gtk.Window()
	w.add(frame)
	w.set_default_size(400, 300)
	w.show_all()
1125
	w.connect('destroy', lambda w: gtk.main_quit())
1126
	gtk.main()
1127

1128
# vim: se ts=3: