gajim-plural/gajim/htmltextview.py

1271 lines
46 KiB
Python
Raw Normal View History

# -*- coding:utf-8 -*-
## 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>
2014-01-02 09:33:54 +01:00
## Copyright (C) 2006-2014 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.
##
## You should have received a copy of the GNU General Public License
## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
##
2009-11-25 21:59:43 +01:00
"""
A Gtk.TextView-based renderer for XHTML-IM, as described in:
2011-03-22 10:56:06 +01:00
http://xmpp.org/extensions/xep-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.
2009-11-25 21:59:43 +01:00
"""
from gi.repository import GObject
from gi.repository import Pango
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf
2016-12-22 17:47:53 +01:00
import xml.sax
import xml.sax.handler
import re
2013-01-02 13:54:02 +01:00
from io import StringIO
import urllib
if __name__ == '__main__':
2017-06-13 23:58:06 +02:00
from gajim.common import i18n
from gajim.common import configpaths
configpaths.init()
from gajim.common import app
2018-07-16 23:22:33 +02:00
from gajim.gtk.util import load_icon
from gajim.gtk.util import get_cursor
from gajim.gtk.util import get_builder
2017-06-13 23:58:06 +02:00
from gajim.common import helpers
2018-07-16 23:22:33 +02:00
from gajim.gtk import JoinGroupchatWindow
from gajim.gtk import AddNewContactWindow
2017-06-13 23:58:06 +02:00
import logging
log = logging.getLogger('gajim.htmlview')
__all__ = ['HtmlTextView']
whitespace_rx = re.compile('\\s+')
allwhitespace_rx = re.compile('^\\s*$')
# embryo of CSS classes
classes = {
#'system-message':';display: none',
'problematic': ';color: red',
}
# styles for elements
element_styles = {
2011-08-09 21:16:26 +02:00
'u' : ';text-decoration: underline',
'em' : ';font-style: oblique',
2011-08-09 21:16:26 +02:00
'cite' : '; background-color:rgb(170,190,250);'
'font-style: oblique',
'li' : '; margin-left: 1em; margin-right: 10%',
'strong' : ';font-weight: bold',
2011-08-09 21:16:26 +02:00
'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']
# ==========
2011-03-23 09:58:01 +01:00
# XEP-0071
# ==========
2008-10-20 17:30:52 +02:00
#
# 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
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', ))
BLOCK_HACKS = set(( 'table', 'tr' )) # at the very least, they will start line ;)
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])
header_size = (num-1) // 2
2018-06-27 00:52:14 +02:00
weight = (num - 1) % 2
element_styles[name] = '; font-size: %s; %s' % ( ('large', 'medium', 'small')[header_size],
2018-06-27 00:52:14 +02:00
('font-weight: bold', 'font-style: oblique')[weight],)
def _parse_css_color(color):
if color.startswith('rgb(') and color.endswith(')'):
r, g, b = [int(c)*257 for c in color[4:-1].split(',')]
return Gdk.Color(r, g, b)
else:
return Gdk.color_parse(color)
def style_iter(style):
return ([x.strip() for x in item.split(':', 1)] for item in style.split(';')\
if len(item.strip()))
2008-12-03 22:56:12 +01:00
class HtmlHandler(xml.sax.handler.ContentHandler):
"""
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.
"""
def __init__(self, textview, conv_textview, startiter):
xml.sax.handler.ContentHandler.__init__(self)
self.textbuf = textview.get_buffer()
self.textview = textview
self.iter = startiter
self.conv_textview = conv_textview
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 _get_points_from_pixels(self, pixels):
resolution = self.textview.get_screen().get_resolution()
# points = pixels * 72 / resolution
return pixels * 72 / resolution
def _parse_style_color(self, tag, value):
color = _parse_css_color(value)
tag.set_property('foreground-gdk', color)
def _parse_style_background_color(self, tag, value):
color = _parse_css_color(value)
tag.set_property('background-gdk', color)
tag.set_property('paragraph-background-gdk', color)
def _get_current_attributes(self):
attrs = self.textview.get_default_attributes()
self.iter.backward_char()
2013-01-10 22:53:09 +01:00
attrs = (self.iter.get_attributes())[1]
self.iter.forward_char()
return attrs
def __parse_length_frac_size_allocate(self, textview, allocation, frac,
callback, args):
callback(allocation.width*frac, *args)
def _parse_length(self, value, font_relative, block_relative, minl, maxl,
callback, *args):
"""
Parse/calc length, converting to pixels, calls callback(length, *args)
when the length is first computed or changes
"""
if value.endswith('%'):
val = float(value[:-1])
if val > 0:
sign = 1
elif val < 0:
sign = -1
else:
sign = 0
# limits: 1% to 500%
val = sign*max(1, min(abs(val), 500))
frac = val/100
if font_relative:
callback(frac, '%', *args)
elif block_relative:
# CSS says 'Percentage values: refer to width of the closest
# block-level ancestor'
# This is difficult/impossible to implement, so we use
# textview width instead; a reasonable approximation..
alloc = self.textview.get_allocation()
self.__parse_length_frac_size_allocate(self.textview, alloc,
frac, callback, args)
self.textview.connect('size-allocate',
self.__parse_length_frac_size_allocate,
frac, callback, args)
else:
callback(frac, *args)
return
def get_val(min_val=minl, max_val=maxl):
try:
val = float(value[:-2])
except:
log.warning('Unable to parse length value "%s"', value)
return None
if val > 0:
sign = 1
elif val < 0:
sign = -1
else:
sign = 0
# validate length
return sign*max(min_val, min(abs(val), max_val))
if value.endswith('pt'): # points
size = get_val(5, 50)
if size is None:
return
callback(size, 'pt', *args)
elif value.endswith('em'):
size = get_val(0.3, 4)
if size is None:
return
callback(size, 'em', *args)
elif value.endswith('px'): # pixels
size = get_val(5, 50)
if size is None:
return
callback(size, 'px', *args)
else:
try:
# TODO: isn't "no units" interpreted as pixels?
val = int(value)
if val > 0:
sign = 1
elif val < 0:
sign = -1
else:
sign = 0
# validate length
val = sign*max(5, min(abs(val), 70))
callback(val, 'px', *args)
except Exception:
log.warning('Unable to parse length value "%s"', value)
def __parse_font_size_cb(self, size, type_, tag):
if type_ in ('em', '%'):
tag.set_property('scale', size)
elif type_ == 'pt':
tag.set_property('size-points', size)
elif type_ == 'px':
tag.set_property('size-points', self._get_points_from_pixels(size))
def _parse_style_display(self, tag, value):
if value == 'none':
tag.set_property('invisible', 'true')
# FIXME: display: block, inline
def _parse_style_font_size(self, tag, value):
try:
2013-01-09 21:43:20 +01:00
# see http://developer.gnome.org/pango/stable/pango-Text-Attributes.html#PANGO-SCALE-XX-SMALL:CAPS
# http://consciouslyusing.blogspot.ru/2012/01/heads-up-missing-pango-text-scale.html
scale = {
2013-01-09 21:43:20 +01:00
#'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,
'xx-small': 0.5787037037037,
'x-small': 0.6444444444444,
'small': 0.8333333333333,
'medium': 1.0,
'large': 1.2,
'x-large': 1.4399999999999,
'xx-large': 1.728,
} [value]
except KeyError:
pass
else:
attrs = self._get_current_attributes()
2013-01-10 22:53:09 +01:00
if attrs.font_scale ==0:
tag.set_property('scale', scale)
return
if value == 'smaller':
2013-01-10 22:53:09 +01:00
tag.set_property('scale', 0.8333333333333)
return
if value == 'larger':
2013-01-10 22:53:09 +01:00
tag.set_property('scale', 1.2)
return
# font relative (5 ~ 4pt, 110 ~ 72pt)
self._parse_length(
value, True, False, 5, 110, self.__parse_font_size_cb, tag)
def _parse_style_font_style(self, tag, value):
try:
style = {
'normal': Pango.Style.NORMAL,
'italic': Pango.Style.ITALIC,
'oblique': Pango.Style.OBLIQUE,
} [value]
except KeyError:
log.warning('unknown font-style %s', value)
else:
tag.set_property('style', style)
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)
def _parse_style_margin_left(self, tag, value):
# block relative
self._parse_length(value, False, True, 1, 1000,
self.__frac_length_tag_cb, tag, 'left-margin')
def _parse_style_margin_right(self, tag, value):
# block relative
self._parse_length(value, False, True, 1, 1000,
self.__frac_length_tag_cb, tag, 'right-margin')
def _parse_style_font_weight(self, tag, value):
# TODO: missing 'bolder' and 'lighter'
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:
log.warning('unknown font-style %s', value)
else:
tag.set_property('weight', weight)
def _parse_style_font_family(self, tag, value):
tag.set_property('family', value)
def _parse_style_text_align(self, tag, value):
try:
align = {
'left': Gtk.Justification.LEFT,
'right': Gtk.Justification.RIGHT,
'center': Gtk.Justification.CENTER,
'justify': Gtk.Justification.FILL,
} [value]
except KeyError:
log.warning('Invalid text-align: %s requested', value)
else:
tag.set_property('justification', align)
def _parse_style_text_decoration(self, tag, value):
values = value.split(' ')
if 'none' in values:
tag.set_property('underline', Pango.Underline.NONE)
tag.set_property('strikethrough', False)
if 'underline' in values:
tag.set_property('underline', Pango.Underline.SINGLE)
else:
tag.set_property('underline', Pango.Underline.NONE)
if 'line-through' in values:
tag.set_property('strikethrough', True)
else:
tag.set_property('strikethrough', False)
if 'blink' in values:
log.warning('text-decoration:blink not implemented')
if 'overline' in values:
log.warning('text-decoration:overline not implemented')
def _parse_style_white_space(self, tag, value):
if value == 'pre':
tag.set_property('wrap_mode', Gtk.WrapMode.NONE)
elif value == 'normal':
tag.set_property('wrap_mode', Gtk.WrapMode.WORD)
elif value == 'nowrap':
tag.set_property('wrap_mode', Gtk.WrapMode.NONE)
def __length_tag_cb(self, value, tag, propname):
try:
tag.set_property(propname, value)
except Exception:
log.warning('Error with prop: %s for tag: %s', propname, 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")
# build a dictionary mapping styles to methods, for greater speed
__style_methods = dict()
for style in ('background-color', 'color', 'font-family', 'font-size',
'font-style', 'font-weight', 'margin-left', 'margin-right',
'text-align', 'text-decoration', 'white-space', 'display',
'width', 'height'):
try:
method = locals()['_parse_style_%s' % style.replace('-', '_')]
except KeyError:
log.warning('Style attribute "%s" not yet implemented', style)
else:
__style_methods[style] = method
del style
# --
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_):
'''Process a url tag.
'''
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.hyperlink_handler, 'url')
tag.set_property('foreground', app.config.get('urlmsgcolor'))
tag.set_property('underline', Pango.Underline.SINGLE)
tag.is_anchor = True
if title:
tag.title = title
return tag
def _update_img(self, output, attrs, img_mark, tags):
'''Callback function called after the function helpers.download_image.
'''
2013-01-02 13:54:02 +01:00
mem, alt = output
self._process_img(attrs, (mem, alt, img_mark, tags))
def _process_img(self, attrs, loaded=None):
'''Process a img tag.
'''
mem = ''
update = False
pixbuf = None
replace_mark = None
replace_tags = None
try:
if attrs['src'].startswith('data:image/'):
# The "data" URL scheme http://tools.ietf.org/html/rfc2397
import base64
img = attrs['src'].split(',')[1]
2013-01-06 09:35:10 +01:00
mem = base64.standard_b64decode(urllib.parse.unquote(
img).encode('utf-8'))
elif loaded is not None:
(mem, alt, replace_mark, replace_tags) = loaded
update = True
else:
if self.conv_textview:
img_mark = self.textbuf.create_mark(None, self.iter, True)
app.thread_interface(helpers.download_image, [
self.conv_textview.account, attrs], self._update_img,
[attrs, img_mark, self._get_style_tags()])
alt = attrs.get('alt', '')
if alt:
alt += '\n'
alt += _('Loading')
2018-07-16 23:22:33 +02:00
pixbuf = load_icon('image-missing', self.textview, pixbuf=True)
if mem:
# Caveat: GdkPixbuf is known not to be safe to load
# images from network... this program is now potentially
# hackable ;)
loader = GdkPixbuf.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,
GdkPixbuf.Pixbuf.scale_simple or similar.
"""
if isinstance(dims[0], float):
dims[0] = int(dims[0]*w)
elif not dims[0]:
dims[0] = w
if isinstance(dims[1], float):
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', '')
working_iter = self.iter
if replace_mark is not None:
working_iter = self.textbuf.get_iter_at_mark(replace_mark)
next_iter = working_iter.copy()
next_iter.forward_char()
self.textbuf.delete(working_iter, next_iter)
self.textbuf.delete_mark(replace_mark)
if pixbuf is not None:
if replace_mark:
tags = replace_tags
else:
tags = self._get_style_tags()
if tags:
tmpmark = self.textbuf.create_mark(None, working_iter, True)
self.textbuf.insert_pixbuf(working_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, working_iter)
self.textbuf.delete_mark(tmpmark)
else:
self._insert_text('[IMG: %s]' % alt, working_iter)
2013-01-01 23:18:36 +01:00
except Exception as ex:
log.error('Error loading image %s', str(ex))
pixbuf = None
alt = attrs.get('alt', 'Broken image')
try:
loader.close()
except Exception:
pass
return pixbuf
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:
tag = self.textbuf.create_tag() # we create anonymous tag
for attr, val in style_iter(style):
attr = attr.lower()
val = val
try:
method = self.__style_methods[attr]
except KeyError:
log.warning('Style attribute "%s" requested '
'but not yet implemented', attr)
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, working_iter=None):
if working_iter == None:
working_iter = self.iter
if self.starting and text != '\n':
self.starting = (text[-1] == '\n')
tags = self._get_style_tags()
if tags:
self.textbuf.insert_with_tags(working_iter, text, *tags)
else:
self.textbuf.insert(working_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:
self._insert_text(text.strip('\n'))
def _anchor_event(self, tag, textview, event, iter_, href, type_):
if event.type == Gdk.EventType.BUTTON_PRESS:
self.textview.emit('url-clicked', href, type_)
return True
return False
def handle_specials(self, text):
if self.conv_textview:
self.iter = self.conv_textview.detect_and_print_special_text(text,
self._get_style_tags(), iter_=self.iter)
else:
self._insert_text(text)
def characters(self, content):
if self.preserve:
self.text += content
return
if allwhitespace_rx.match(content) is not None and self._starts_line():
return
self.text += content
self.starting = False
def startElement(self, name, attrs):
self._flush_text()
klass = [i for i in attrs.get('class', ' ').split(' ') if i]
style = ''
#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':
#TODO: accesskey, charset, hreflang, rel, rev, tabindex, type
href = attrs.get('href', None)
if not href:
href = attrs.get('HREF', None)
# Gaim sends HREF instead of href
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_)
2016-11-05 16:30:34 +01:00
tag.title = attrs.get('title', None)
tag.is_anchor = True
elif name in LIST_ELEMS:
style += ';margin-left: 2em'
elif name == 'img':
tag = self._process_img(attrs)
if name in element_styles:
style += element_styles[name]
# so that explicit styles override implicit ones,
# we add the attribute last
style += ";"+attrs.get('style', '')
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:
2013-01-10 22:53:09 +01:00
li_head = chr(0x2022)
else:
self.list_counters[-1] += 1
li_head = '%i.' % self.list_counters[-1]
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()
elif name in ('a', 'img', 'body', 'html'):
pass
elif name in INLINE:
pass
else:
log.warning('Unhandled element "%s"', name)
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()
self._insert_text('\u2015'*40)
self._jump_line()
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
elif name in BLOCK_STRUCT:
newLine = True
else:
log.warning("Unhandled element '%s'", name)
self._flush_text()
if endPreserving:
self.preserve = False
if newLine:
self._jump_line()
self._end_span()
2013-01-10 22:53:09 +01:00
class HtmlTextView(Gtk.TextView):
2008-12-03 22:56:12 +01:00
def __init__(self):
2017-07-28 19:25:19 +02:00
Gtk.TextView.__init__(self)
self.set_wrap_mode(Gtk.WrapMode.CHAR)
self.set_editable(False)
self._changed_cursor = False
self.set_has_tooltip(True)
self.connect('realize', self.on_html_text_view_realized)
self.connect('unrealize', self.on_html_text_view_unrealized)
self.connect('copy-clipboard', self.on_html_text_view_copy_clipboard)
self.id_ = self.connect('button-release-event',
self.on_left_mouse_button_release)
self.get_buffer().eol_tag = self.get_buffer().create_tag('eol')
self.config = app.config
self.interface = app.interface
# end big hack
def connect_tooltip(self, func=None):
self.connect('query-tooltip', func or self.__query_tooltip)
def create_tags(self):
buffer_ = self.get_buffer()
self.tagURL = buffer_.create_tag('url')
color = app.config.get('urlmsgcolor')
self.tagURL.set_property('foreground', color)
self.tagURL.set_property('underline', Pango.Underline.SINGLE)
self.tagURL.connect('event', self.hyperlink_handler, 'url')
self.tagMail = buffer_.create_tag('mail')
self.tagMail.set_property('foreground', color)
self.tagMail.set_property('underline', Pango.Underline.SINGLE)
self.tagMail.connect('event', self.hyperlink_handler, 'mail')
self.tagXMPP = buffer_.create_tag('xmpp')
self.tagXMPP.set_property('foreground', color)
self.tagXMPP.set_property('underline', Pango.Underline.SINGLE)
self.tagXMPP.connect('event', self.hyperlink_handler, 'xmpp')
self.tagSthAtSth = buffer_.create_tag('sth_at_sth')
self.tagSthAtSth.set_property('foreground', color)
self.tagSthAtSth.set_property('underline', Pango.Underline.SINGLE)
self.tagSthAtSth.connect('event', self.hyperlink_handler, 'sth_at_sth')
def __query_tooltip(self, widget, x_pos, y_pos, keyboard_mode, tooltip):
window = widget.get_window(Gtk.TextWindowType.TEXT)
x_pos, y_pos = self.window_to_buffer_coords(
Gtk.TextWindowType.TEXT, x_pos, y_pos)
if Gtk.MINOR_VERSION > 18:
iter_ = self.get_iter_at_position(x_pos, y_pos)[1]
else:
iter_ = self.get_iter_at_position(x_pos, y_pos)[0]
for tag in iter_.get_tags():
if getattr(tag, 'is_anchor', False):
text = getattr(tag, 'title', False)
if text:
if len(text) > 50:
text = text[:47] + ''
tooltip.set_text(text)
if not self._changed_cursor:
2018-07-16 23:22:33 +02:00
window.set_cursor(get_cursor('HAND2'))
self._changed_cursor = True
return True
if self._changed_cursor:
2018-07-16 23:22:33 +02:00
window.set_cursor(get_cursor('XTERM'))
self._changed_cursor = False
return False
def on_open_link_activate(self, widget, kind, text):
helpers.launch_browser_mailer(kind, text)
def on_copy_link_activate(self, widget, text):
2013-08-24 19:14:58 +02:00
clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clip.set_text(text, -1)
# def on_start_chat_activate(self, widget, jid):
# app.interface.new_chat_from_jid(self.account, jid)
def on_join_group_chat_menuitem_activate(self, widget, room_jid):
2018-07-16 23:22:33 +02:00
JoinGroupchatWindow(None, room_jid)
def on_add_to_roster_activate(self, widget, jid):
2018-07-16 23:22:33 +02:00
AddNewContactWindow(self.account, jid)
def make_link_menu(self, event, kind, text):
2018-07-16 23:22:33 +02:00
xml = get_builder('chat_context_menu.ui')
menu = xml.get_object('chat_context_menu')
childs = menu.get_children()
if kind == 'url':
childs[0].connect('activate', self.on_copy_link_activate, text)
childs[1].connect('activate', self.on_open_link_activate, kind,
text)
childs[2].hide() # copy mail address
childs[3].hide() # open mail composer
childs[4].hide() # jid section separator
childs[5].hide() # start chat
childs[6].hide() # join group chat
childs[7].hide() # add to roster
else: # It's a mail or a JID
# load muc icon
join_group_chat_menuitem = xml.get_object('join_group_chat_menuitem')
text = text.lower()
if text.startswith('xmpp:'):
text = text[5:]
childs[2].connect('activate', self.on_copy_link_activate, text)
childs[3].connect('activate', self.on_open_link_activate, kind,
text)
# childs[5].connect('activate', self.on_start_chat_activate, text)
childs[6].connect('activate',
self.on_join_group_chat_menuitem_activate, text)
# if self.account and app.connections[self.account].\
# roster_supported:
# childs[7].connect('activate',
# self.on_add_to_roster_activate, text)
# childs[7].show() # show add to roster menuitem
# else:
# childs[7].hide() # hide add to roster menuitem
if kind == 'xmpp':
childs[0].connect('activate', self.on_copy_link_activate,
'xmpp:' + text)
childs[2].hide() # copy mail address
childs[3].hide() # open mail composer
elif kind == 'mail':
childs[6].hide() # join group chat
if kind != 'xmpp':
childs[0].hide() # copy link location
childs[1].hide() # open link in browser
childs[4].hide() # jid section separator
childs[5].hide() # start chat
childs[7].hide() # add to roster
menu.popup(None, None, None, event.button, event.time)
def hyperlink_handler(self, texttag, widget, event, iter_, kind):
2013-08-24 19:14:58 +02:00
if event.type == Gdk.EventType.BUTTON_PRESS:
begin_iter = iter_.copy()
2018-06-27 00:52:14 +02:00
# we get the beginning of the tag
while not begin_iter.begins_tag(texttag):
begin_iter.backward_char()
end_iter = iter_.copy()
# we get the end of the tag
while not end_iter.ends_tag(texttag):
end_iter.forward_char()
# Detect XHTML-IM link
word = getattr(texttag, 'href', None)
if word:
if word.startswith('xmpp'):
kind = 'xmpp'
elif word.startswith('mailto:'):
kind = 'mail'
elif app.interface.sth_at_sth_dot_sth_re.match(word):
# it's a JID or mail
kind = 'sth_at_sth'
else:
word = self.textview.get_buffer().get_text(begin_iter,
2013-08-24 19:14:58 +02:00
end_iter)
if event.button == 3: # right click
self.make_link_menu(event, kind, word)
return True
else:
# we launch the correct application
if kind == 'xmpp':
word = word[5:]
if '?' in word:
(jid, action) = word.split('?')
if action == 'join':
self.on_join_group_chat_menuitem_activate(None, jid)
else:
self.on_start_chat_activate(None, jid)
else:
self.on_start_chat_activate(None, word)
else:
helpers.launch_browser_mailer(kind, word)
def _hyperlink_handler(self, texttag, widget, event, iter_, kind):
# self.hyperlink_handler can be overwritten, so call it when needed
self.hyperlink_handler(texttag, widget, event, iter_, kind)
def display_html(self, html, textview, conv_textview, iter_=None):
buffer_ = self.get_buffer()
if iter_:
eob = iter_
else:
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(textview, conv_textview, eob))
parser.parse(StringIO(html))
# too much space after :)
#if not eob.starts_line():
# buffer_.insert(eob, '\n')
def on_html_text_view_copy_clipboard(self, unused_data):
clipboard = self.get_clipboard(Gdk.SELECTION_CLIPBOARD)
2013-01-07 22:33:27 +01:00
selected = self.get_selected_text()
2013-01-07 22:50:46 +01:00
clipboard.set_text(selected, -1)
2017-07-28 19:25:19 +02:00
GObject.signal_stop_emission_by_name(self, 'copy-clipboard')
def on_html_text_view_realized(self, unused_data):
self.get_buffer().remove_selection_clipboard(self.get_clipboard(
Gdk.SELECTION_PRIMARY))
def on_html_text_view_unrealized(self, unused_data):
self.get_buffer().add_selection_clipboard(self.get_clipboard(
Gdk.SELECTION_PRIMARY))
def on_left_mouse_button_release(self, widget, event):
if event.button != 1:
return
2011-06-25 22:28:42 +02:00
bounds = self.get_buffer().get_selection_bounds()
if bounds:
# textview can be hidden while we add a new line in it.
if self.has_screen():
clipboard = self.get_clipboard(Gdk.SELECTION_PRIMARY)
2013-01-07 22:33:27 +01:00
selected = self.get_selected_text()
2013-01-07 22:50:46 +01:00
clipboard.set_text(selected, -1)
def get_selected_text(self):
bounds = self.get_buffer().get_selection_bounds()
selection = ''
if bounds:
(search_iter, end) = bounds
while (search_iter.compare(end)):
character = search_iter.get_char()
2013-01-01 19:44:25 +01:00
if character == '\ufffc':
anchor = search_iter.get_child_anchor()
if anchor:
2013-02-18 20:12:45 +01:00
text = anchor.plaintext
if text:
selection+=text
else:
selection+=character
else:
selection+=character
search_iter.forward_char()
return selection
change_cursor = None
if __name__ == '__main__':
2018-03-06 11:16:29 +01:00
from gajim.conversation_textview import ConversationTextview
from gajim.gui_interface import Interface
from gajim.common import app, logger, caps_cache
2018-06-27 00:52:14 +02:00
# TODO: don't call Logger() it will create the DB
2018-04-22 22:16:17 +02:00
# maybe mock this object for tests
# app.logger = logger.Logger()
# caps_cache.initialize(app.logger)
2018-03-06 11:16:29 +01:00
Interface()
# create fake app.plugin_manager.gui_extension_point method for tests
2018-03-06 11:16:29 +01:00
def extension_point(*args):
pass
def gui_extension_point(*args):
pass
2018-03-06 11:16:29 +01:00
app.plugin_manager = Interface()
app.plugin_manager.extension_point = extension_point
app.plugin_manager.gui_extension_point = gui_extension_point
htmlview = ConversationTextview(None)
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
w = htmlview.tv.get_window(Gtk.TextWindowType.TEXT)
device = w.get_display().get_device_manager().get_client_pointer()
pointer = w.get_device_position(device)
x = pointer[1]
y = pointer[2]
2018-03-06 11:16:29 +01:00
tags = htmlview.tv.get_iter_at_location(x, y)[1].get_tags()
if change_cursor:
2018-07-16 23:22:33 +02:00
w.set_cursor(get_cursor('XTERM'))
change_cursor = None
tag_table = htmlview.tv.get_buffer().get_tag_table()
for tag in tags:
try:
if tag.is_anchor:
2018-07-16 23:22:33 +02:00
w.set_cursor(get_cursor('HAND2'))
change_cursor = tag
elif tag == tag_table.lookup('focus-out-line'):
over_line = True
except Exception:
pass
htmlview.tv.connect('motion_notify_event', on_textview_motion_notify_event)
def handler(texttag, widget, event, iter_, kind):
if event.type == Gdk.EventType.BUTTON_PRESS:
pass
htmlview.tv.hyperlink_handler = htmlview.hyperlink_handler
htmlview.print_real_text(None, xhtml='<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')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<body xmlns='http://www.w3.org/1999/xhtml'>
<p xmlns='http://www.w3.org/1999/xhtml'>a:b
<a href='http://google.com/' xmlns='http://www.w3.org/1999/xhtml'>Google
</a>
</p><br/>
</body>''')
htmlview.print_real_text(None, xhtml='''
<body xmlns='http://www.w3.org/1999/xhtml'>
<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>
</body>
''')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<body xmlns='http://www.w3.org/1999/xhtml'>
http://test.com/ testing links autolinkifying
</body>
''')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<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>
''')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<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.xmpp.org/images/psa-license.jpg'
alt='A License to Jabber' width='50%' height='50%'/>
</p>
</body>
''')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<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)
if n&lt;0: raise ValueError('Must be non-negative')
return faciter(n,1)</pre>
</body>
''')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<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>
<li style='font-size:50%'> Two </li>
<li style='font-size:200%'> Three </li>
<li style='font-size:9999pt'> Four </li>
</ul></li>
<li> Three </li></ol>
</body>
''')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<body xmlns='http://www.w3.org/1999/xhtml'>
<p>
<strong>
<a href='xmpp:example@example.org'>xmpp link</a>
</strong>: </p>
<div xmlns='http://www.w3.org/1999/xhtml'>
<cite style='margin: 7px;' title='xmpp:examples@example.org'>
<p>
<strong>examples@example.org wrote:</strong>
</p>
<p>this cite - bla bla bla, smile- :-) </p>
</cite>
<div>
<p>some text</p>
</div>
</div>
<p/>
<p>#232/1</p>
</body>
''')
htmlview.print_real_text(None, xhtml='<hr />')
htmlview.print_real_text(None, xhtml='''
<body xmlns='http://www.w3.org/1999/xhtml'>
<br/>
<img src='data:image/png;base64,R0lGODdhMAAwAPAAAAAAAP///ywAAAAAMAAw\
AAAC8IyPqcvt3wCcDkiLc7C0qwyGHhSWpjQu5yqmCYsapyuvUUlvONmOZtfzgFz\
ByTB10QgxOR0TqBQejhRNzOfkVJ+5YiUqrXF5Y5lKh/DeuNcP5yLWGsEbtLiOSp\
a/TPg7JpJHxyendzWTBfX0cxOnKPjgBzi4diinWGdkF8kjdfnycQZXZeYGejmJl\
ZeGl9i2icVqaNVailT6F5iJ90m6mvuTS4OK05M0vDk0Q4XUtwvKOzrcd3iq9uis\
F81M1OIcR7lEewwcLp7tuNNkM3uNna3F2JQFo97Vriy/Xl4/f1cf5VWzXyym7PH\
hhx4dbgYKAAA7' alt='Larry'/>
</body>
''')
htmlview.tv.show()
sw = Gtk.ScrolledWindow()
sw.set_property('hscrollbar-policy', Gtk.PolicyType.AUTOMATIC)
sw.set_property('vscrollbar-policy', Gtk.PolicyType.AUTOMATIC)
sw.set_property('border-width', 0)
sw.add(htmlview.tv)
sw.show()
frame = Gtk.Frame()
frame.set_shadow_type(Gtk.ShadowType.IN)
frame.show()
frame.add(sw)
w = Gtk.Window()
w.add(frame)
w.set_default_size(400, 300)
w.show_all()
w.connect('destroy', lambda w: Gtk.main_quit())
Gtk.main()