### Copyright (C) 2005-2007 Gustavo J. A. M. Carneiro
###
### This library is free software; you can redistribute it and/or
### modify it under the terms of the GNU Lesser General Public
### License as published by the Free Software Foundation; either
### version 2 of the License, or (at your option) any later version.
###
### This library 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
### Lesser General Public License for more details.
###
### You should have received a copy of the GNU Lesser General Public
### License along with this library; if not, write to the
### Free Software Foundation, Inc., 59 Temple Place - Suite 330,
### Boston, MA 02111-1307, USA.
'''
A gtk.TextView-based renderer for XHTML-IM, as described in:
http://www.jabber.org/jeps/jep-0071.html
'''
import gobject
import pango
import gtk
import xml.sax
import xml.sax.handler
import re
import warnings
from cStringIO import StringIO
import operator
__all__ = ['HtmlTextView']
whitespace_rx = re.compile("\\s+")
def _parse_css_color(color):
'''_parse_css_color(css_color) -> gtk.gdk.Color'''
if color.startswith("rgb(") and color.endswith(')'):
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)
# class HtmlEntityResolver(xml.sax.handler.EntityResolver):
# def resolveEntity(publicId, systemId):
# pass
class HtmlHandler(xml.sax.handler.ContentHandler):
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.styles = [] # a list of sequences of gtk.TextTag, 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)
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)
if gtk.gtk_version >= (2, 8):
tag.set_property("paragraph-background-gdk", color)
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:
## Workaround http://bugzilla.gnome.org/show_bug.cgi?id=317455
def _get_current_style_attr(self, propname, comb_oper=None):
tags = [tag for tags in self.styles for tag in tags]
tags.reverse()
is_set_name = propname + "-set"
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):
__slots__ = ("font", "font_scale")
def _get_current_attributes(self):
attrs = self._FakeAttrs()
attrs.font_scale = self._get_current_style_attr("scale",
operator.mul)
if attrs.font_scale is None:
attrs.font_scale = 1.0
attrs.font = self._get_current_style_attr("font-desc")
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)
def _parse_length(self, value, font_relative, callback, *args):
'''Parse/calc length, converting to pixels, calls callback(length, *args)
when the length is first computed or changes'''
if value.endswith('%'):
frac = float(value[:-1])/100
if font_relative:
attrs = self._get_current_attributes()
font_size = attrs.font.get_size()
callback(frac*font_size, True, *args)
else:
## 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)
elif value.endswith('pt'): # points
callback(float(value[:-2]), False, *args)
elif value.endswith('em'): # ems, the height of the element's font
attrs = self._get_current_attributes()
font_size = attrs.font.get_size()
callback(float(value[:-2])*font_size, True, *args)
elif value.endswith('ex'): # x-height, ~ the height of the letter 'x'
## FIXME: figure out how to calculate this correctly
## for now 'em' size is used as approximation
attrs = self._get_current_attributes()
font_size = attrs.font.get_size()
callback(float(value[:-2])*font_size, True, *args)
elif value.endswith('px'): # pixels
callback(int(value[:-2])*pango.SCALE, True, *args)
else:
warnings.warn("Unable to parse length value '%s'" % value)
@staticmethod
def __parse_font_size_cb(length, pango_units, tag):
if pango_units:
tag.set_property("size", length)
else: # points
tag.set_property("size-points", length)
def _parse_style_font_size(self, tag, value):
try:
scale = {
"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,
} [value]
except KeyError:
pass
else:
attrs = self._get_current_attributes()
tag.set_property("scale", scale / attrs.font_scale)
return
if value == 'smaller':
tag.set_property("scale", pango.SCALE_SMALL)
return
if value == 'larger':
tag.set_property("scale", pango.SCALE_LARGE)
return
self._parse_length(value, True, 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:
warnings.warn("unknown font-style %s" % value)
else:
tag.set_property("style", style)
@staticmethod
def __frac_length_tag_cb(length, pango_units, tag, propname):
if pango_units:
length = length / pango.SCALE
# else assume points = pixels, whatever
tag.set_property(propname, length)
def _parse_style_margin_left(self, tag, value):
self._parse_length(value, False, self.__frac_length_tag_cb,
tag, "left-margin")
def _parse_style_margin_right(self, tag, value):
self._parse_length(value, False, 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:
warnings.warn("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.JUSTIFY_LEFT,
'right': gtk.JUSTIFY_RIGHT,
'center': gtk.JUSTIFY_CENTER,
'justify': gtk.JUSTIFY_FILL,
} [value]
except KeyError:
warnings.warn("Invalid text-align:%s requested" % value)
else:
tag.set_property("justification", align)
def _parse_style_text_decoration(self, tag, value):
if value == "none":
tag.set_property("underline", pango.UNDERLINE_NONE)
tag.set_property("strikethrough", False)
elif value == "underline":
tag.set_property("underline", pango.UNDERLINE_SINGLE)
tag.set_property("strikethrough", False)
elif value == "overline":
warnings.warn("text-decoration:overline not implemented")
tag.set_property("underline", pango.UNDERLINE_NONE)
tag.set_property("strikethrough", False)
elif value == "line-through":
tag.set_property("underline", pango.UNDERLINE_NONE)
tag.set_property("strikethrough", True)
elif value == "blink":
warnings.warn("text-decoration:blink not implemented")
else:
warnings.warn("text-decoration:%s not implemented" % value)
def _parse_style_vertical_align(self, tag, value):
if value == 'sub':
tag.set_property("rise", -10000 / 2)
tag.set_property("scale", pango.SCALE_SMALL)
elif value == 'super':
tag.set_property("rise", 10000 / 2)
tag.set_property("scale", pango.SCALE_SMALL)
else:
warnings.warn("Unsupported or invalid vertical-align:%s requested" % value)
## 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", "vertical-align"]:
try:
method = locals()["_parse_style_%s" % style.replace('-', '_')]
except KeyError:
warnings.warn("Style attribute '%s' not yet implemented" % style)
else:
__style_methods[style] = method
def _get_style_tags(self):
return [tag for tags in self.styles for tag in tags]
def _begin_span(self, style, tags=()):
if style is None:
self.styles.append(tags)
return None
tags = list(tags)
for item in style.split(';'):
item = item.strip()
if item == "":
continue
try:
attr, val = item.split(':', 1)
except ValueError:
raise ValueError("the '%s' style is malformed" % item)
attr = attr.rstrip().lower()
val = val.lstrip()
try:
method = self.__style_methods[attr]
except KeyError:
warnings.warn("Style attribute '%s' requested "
"but not yet implemented" % attr)
else:
tag = self.textbuf.create_tag()
tags.append(tag)
method(self, tag, val)
self.styles.append(tags)
def _end_span(self):
self.styles.pop(-1)
def _insert_text(self, text):
tags = self._get_style_tags()
if tags:
self.textbuf.insert_with_tags(self.iter, text, *tags)
else:
self.textbuf.insert(self.iter, text)
def _flush_text(self):
if not self.text:
return
self.text = self.text.replace('\n', ' ')
if self.iter.starts_line():
self.text = self.text.lstrip(' ')
self._insert_text(self.text)
self.text = ''
def _anchor_event(self, _tag, _textview, event, _iter, href, type_):
if event.type == gtk.gdk.BUTTON_PRESS and event.button == 1:
self.textview.emit("url-clicked", href, type_)
return True
return False
def characters(self, content):
self.text += whitespace_rx.sub(' ', content)
_TAG_STYLES = {
'b': 'font-weight: bold',
'big': 'font-size: large',
'cite': 'font-style: italic',
'code': 'font-family: monospace',
'dfn': 'font-style: italic',
'em': 'font-style: italic',
'i': 'font-style: italic',
'kbd': 'font-family: monospace',
'samp': 'font-family: monospace',
'small': 'font-size: small',
'strong': 'font-weight: bold',
'sub': 'vertical-align: sub',
'sup': 'vertical-align: super',
'u': 'text-decoration: underline',
'var': 'font-style: italic',
}
_NOP_TAGS = frozenset((
# The above
'b', 'big', 'cite', 'code', 'dfn', 'em', 'i', 'kbd',
'samp', 'small', 'strong', 'sub', 'sup', 'u', 'var',
# Some extra
'a', 'body', 'span'))
def startElement(self, name, attrs):
self._flush_text()
if 'style' in attrs:
style = attrs['style']
elif name in self._TAG_STYLES:
style = self._TAG_STYLES[name]
else:
style = None
tags = ()
if name == 'a':
anchor_tag = self.textbuf.create_tag()
try:
type_ = attrs['type']
except KeyError:
type_ = None
anchor_tag.connect('event', self._anchor_event, attrs['href'], type_)
anchor_tag.is_anchor = True
tags = (self.textview.link_tag, anchor_tag)
self._begin_span(style, tags)
if name == 'br':
pass # handled in endElement
elif name == 'p':
if not self.iter.starts_line():
self._insert_text("\n")
elif name == 'div':
if not self.iter.starts_line():
self._insert_text("\n")
elif name == 'ul':
if not self.iter.starts_line():
self._insert_text("\n")
self.list_counters.insert(0, None)
elif name == 'ol':
if not self.iter.starts_line():
self._insert_text("\n")
self.list_counters.insert(0, 0)
elif name == 'li':
if self.list_counters[0] is None:
li_head = unichr(0x2022)
else:
self.list_counters[0] += 1
li_head = "%i." % self.list_counters[0]
self.text = ' '*len(self.list_counters)*4 + li_head + ' '
elif name == 'img':
try:
# Loading arbitrary images is disabled for security reasons.
# Apparently GdkPixbuf isn't safe for loading arbitrary images.
# Anyway, loading from the network would block, which we
# probably don't want.
pixbuf = self.textview.images[attrs['src']]
except Exception:
pixbuf = None
try:
alt = attrs['alt']
except KeyError:
alt = "Broken image"
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)
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)
elif name in self._NOP_TAGS:
pass
else:
warnings.warn("Unhandled element '%s'" % name)
def endElement(self, name):
self._flush_text()
if name == 'p':
if not self.iter.starts_line():
self._insert_text("\n")
elif name == 'div':
if not self.iter.starts_line():
self._insert_text("\n")
elif name == 'br':
self._insert_text("\n")
elif name == 'ul':
self.list_counters.pop()
elif name == 'ol':
self.list_counters.pop()
elif name == 'li':
self._insert_text("\n")
elif name == 'img':
pass
elif name in self._NOP_TAGS:
pass
else:
warnings.warn("Unhandled element '%s'" % name)
self._end_span()
class HtmlTextView(gtk.TextView):
__gtype_name__ = 'HtmlTextView'
__gsignals__ = {
'url-clicked': (gobject.SIGNAL_RUN_LAST, None, (str, str)), # href, type
}
__gproperties__ = {
'labellike' :
(gobject.TYPE_BOOLEAN,
"labellike",
"Custom CodeWeavers control for label-like appearance",
False,
gobject.PARAM_READABLE | gobject.PARAM_WRITABLE | gobject.PARAM_CONSTRUCT)
}
def __init__(self):
gtk.TextView.__init__(self)
self.set_wrap_mode(gtk.WRAP_CHAR)
self.set_editable(False)
self._changed_cursor = False
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)
self.connect("style-set", self.__style_set)
self.connect("parent-set", self.__parent_set)
self._parent_style_handler = None
self.set_pixels_above_lines(3)
self.set_pixels_below_lines(3)
self.images = {} # a dictionary of strings to gdkpixbuf objects for
# images that are acceptable in html
self.link_tag = self.get_buffer().create_tag()
self.link_tag.set_property('underline', pango.UNDERLINE_SINGLE)
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 __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()
for tag in tags:
if getattr(tag, 'is_anchor', False):
is_over_anchor = True
break
else:
is_over_anchor = False
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
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 __style_set(self, widget, _previous_style):
link_color = widget.get_style().lookup_color('link_color')
if link_color is None:
link_color = gtk.gdk.color_parse('#0000ff')
self.link_tag.set_property('foreground-gdk', link_color)
def __parent_style_set(self, _widget, _previous_style):
parent = self.get_parent()
if parent is not None and parent.get_style():
self.modify_base(gtk.STATE_NORMAL, parent.get_style().bg[gtk.STATE_NORMAL])
def __parent_set(self, _widget, old_parent):
if self._parent_style_handler is not None:
old_parent.disconnect(self._parent_style_handler)
self._parent_style_handler = None
if self.get_parent() is not None:
self._parent_style_handler = self.get_parent().connect('style_set', self.__parent_style_set)
self.__parent_style_set(self.get_parent(), None)
def do_set_property(self, prop, value):
if prop.name == 'labellike':
self.labellike = value
if self.labellike:
self.set_wrap_mode(gtk.WRAP_WORD)
self.set_editable(False)
self.set_cursor_visible(False)
self.__style_set(self, None)
else:
super(HtmlTextView, self).do_set_property(prop, value)
def display_html(self, html):
buf = self.get_buffer()
eob = buf.get_end_iter()
## this works too if libxml2 is not available
#parser = xml.sax.make_parser(['drv_libxml2'])
parser = xml.sax.make_parser()
# parser.setFeature(xml.sax.handler.feature_validation, True)
parser.setContentHandler(HtmlHandler(self, eob))
#parser.setEntityResolver(HtmlEntityResolver())
# The parser expects XHTML
html = html.replace('<br>', '<br/>').replace('</br>', '')
# The word-break tag is not supported
html = html.replace('<wbr>', '').replace('</wbr>', '')
# is an important HTML typographic entity (e.g. in French) but
# is not supported by the XML parser. So try to manually define it
# along with some other commonly used entities.
if not html.startswith('<!DOCTYPE'):
html = '<!DOCTYPE body [ ' + \
'<!ENTITY copy "©"> ' + \
'<!ENTITY hellip "…"> ' + \
'<!ENTITY mdash "—"> ' + \
'<!ENTITY nbsp " "> ' + \
'<!ENTITY reg "®"> ' + \
'<!ENTITY trade "™"> ' + \
']>' + html
if isinstance(html, unicode):
# the xml parser has trouble with unicode sometimes
html = html.encode('utf8')
parser.parse(StringIO(html))
if eob.starts_line():
eob_1 = buf.get_end_iter()
eob_1.backward_char()
buf.delete(eob_1, eob)
if gobject.pygtk_version < (2, 8):
gobject.type_register(HtmlTextView)
def glade_create(_str1, _str2, _int1, _int2):
widget = HtmlTextView()
return widget
if __name__ == '__main__':
htmlview = HtmlTextView()
def url_cb(view, url, type_):
print "url-clicked", url, type_
htmlview.connect("url-clicked", url_cb)
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')
htmlview.display_html("<br/>")
htmlview.display_html("""
<p style='font-size:large'>
<span style='font-style: italic'>O<span style='font-size:larger'>M</span>G</span>,
I'm <span style='color:green'>green</span>
with <span style='font-weight: bold'>envy</span>!
</p>
""")
htmlview.display_html("<br/>")
htmlview.display_html("""
<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%'>
"A foolish consistency is the hobgoblin of little minds."
</p>
</body>
""")
htmlview.display_html("<br/>")
htmlview.display_html("""
<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'
alt='A License to Jabber'
height='261'
width='537'/></p>
</body>
""")
htmlview.display_html("""
<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>
</body>
""")
htmlview.display_html("""
<body xmlns='http://www.w3.org/1999/xhtml'>
<ol>
<li> One </li>
<li> Two </li>
<li> Three </li>
</ol>
</body>
""")
htmlview.display_html("""
<p>
Can we add button to return in project root directory in filemanager ?
</p>
""")
htmlview.display_html('<body><span style="font-family: monospace">|   |</span></body>')
htmlview.show()
sw = gtk.ScrolledWindow()
sw.set_property("hscrollbar-policy", gtk.POLICY_AUTOMATIC)
sw.set_property("vscrollbar-policy", gtk.POLICY_AUTOMATIC)
sw.set_property("border-width", 0)
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()
w.connect("destroy", lambda w: gtk.main_quit())
gtk.main()