comparison src/cagou/core/simple_xhtml.py @ 106:9909ed7a7a20

moved SimpleXHTMLWidget to a dedicated module
author Goffi <goffi@goffi.org>
date Sun, 15 Jan 2017 21:21:20 +0100
parents src/cagou/plugins/plugin_wid_chat.py@ce6ef88f2cff
children
comparison
equal deleted inserted replaced
105:ce6ef88f2cff 106:9909ed7a7a20
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Cagou: desktop/mobile frontend for Salut à Toi XMPP client
5 # Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20
21 from sat.core import log as logging
22 log = logging.getLogger(__name__)
23 from kivy.uix.stacklayout import StackLayout
24 from kivy.uix.label import Label
25 from kivy.utils import escape_markup
26 from kivy import properties
27 from xml.etree import ElementTree as ET
28 from sat_frontends.tools import css_color, strings as sat_strings
29 from cagou.core.image import AsyncImage
30 import webbrowser
31
32
33 class Escape(unicode):
34 """Class used to mark that a message need to be escaped"""
35
36 def __init__(self, text):
37 super(Escape, self).__init__(text)
38
39
40 class SimpleXHTMLWidgetEscapedText(Label):
41
42 def _addUrlMarkup(self, text):
43 text_elts = []
44 idx = 0
45 links = 0
46 while True:
47 m = sat_strings.RE_URL.search(text[idx:])
48 if m is not None:
49 text_elts.append(escape_markup(m.string[0:m.start()]))
50 link_key = u'link_' + unicode(links)
51 url = m.group()
52 text_elts.append(u'[color=5500ff][ref={link}]{url}[/ref][/color]'.format(
53 link = link_key,
54 url = url
55 ))
56 if not links:
57 self.ref_urls = {link_key: url}
58 else:
59 self.ref_urls[link_key] = url
60 links += 1
61 idx += m.end()
62 else:
63 if links:
64 text_elts.append(escape_markup(text[idx:]))
65 self.markup = True
66 self.text = u''.join(text_elts)
67 break
68
69 def on_text(self, instance, text):
70 # do NOT call the method if self.markup is set
71 # this would result in infinite loop (because self.text
72 # is changed if an URL is found, and in this case markup too)
73 if text and not self.markup:
74 self._addUrlMarkup(text)
75
76 def on_ref_press(self, ref):
77 url = self.ref_urls[ref]
78 webbrowser.open(url)
79
80
81 class SimpleXHTMLWidgetText(Label):
82 pass
83
84
85 class SimpleXHTMLWidgetImage(AsyncImage):
86 # following properties are desired height/width
87 # i.e. the ones specified in height/width attributes of <img>
88 # (or wanted for whatever reason)
89 # set to 0 to ignore them
90 target_height = properties.NumericProperty()
91 target_width = properties.NumericProperty()
92
93 def _get_parent_container(self):
94 """get parent SimpleXHTMLWidget instance
95
96 @param warning(bool): if True display a log.error if nothing found
97 @return (SimpleXHTMLWidget, None): found SimpleXHTMLWidget instance
98 """
99 parent = self.parent
100 while parent and not isinstance(parent, SimpleXHTMLWidget):
101 parent = parent.parent
102 if parent is None:
103 log.error(u"no SimpleXHTMLWidget parent found")
104 return parent
105
106 def _on_source_load(self, value):
107 # this method is called when image is loaded
108 super(SimpleXHTMLWidgetImage, self)._on_source_load(value)
109 if self.parent is not None:
110 container = self._get_parent_container()
111 # image is loaded, we need to recalculate size
112 self.on_container_width(container, container.width)
113
114 def on_container_width(self, container, container_width):
115 """adapt size according to container width
116
117 called when parent container (SimpleXHTMLWidget) width change
118 """
119 target_size = (self.target_width or self.texture.width, self.target_height or self.texture.height)
120 padding = container.padding
121 padding_h = (padding[0] + padding[2]) if len(padding) == 4 else padding[0]
122 width = container_width - padding_h
123 if target_size[0] < width:
124 self.size = target_size
125 else:
126 height = width / self.image_ratio
127 self.size = (width, height)
128
129 def on_parent(self, instance, parent):
130 if parent is not None:
131 container = self._get_parent_container()
132 container.bind(width=self.on_container_width)
133
134
135 class SimpleXHTMLWidget(StackLayout):
136 """widget handling simple XHTML parsing"""
137 xhtml = properties.StringProperty()
138 color = properties.ListProperty([1, 1, 1, 1])
139 # XXX: bold is only used for escaped text
140 bold = properties.BooleanProperty(False)
141 content_width = properties.NumericProperty(0)
142
143 # text/XHTML input
144
145 def on_xhtml(self, instance, xhtml):
146 """parse xhtml and set content accordingly
147
148 if xhtml is an instance of Escape, a Label with not markup
149 will be used
150 """
151 self.clear_widgets()
152 if isinstance(xhtml, Escape):
153 label = SimpleXHTMLWidgetEscapedText(text=xhtml, color=self.color)
154 self.bind(color=label.setter('color'))
155 self.bind(bold=label.setter('bold'))
156 self.add_widget(label)
157 else:
158 xhtml = ET.fromstring(xhtml.encode('utf-8'))
159 self.current_wid = None
160 self.styles = []
161 self._callParseMethod(xhtml)
162
163 def escape(self, text):
164 """mark that a text need to be escaped (i.e. no markup)"""
165 return Escape(text)
166
167 # sizing
168
169 def on_width(self, instance, width):
170 if len(self.children) == 1:
171 wid = self.children[0]
172 if isinstance(wid, Label):
173 # we have simple text
174 try:
175 full_width = wid._full_width
176 except AttributeError:
177 # on first time, we need the required size
178 # for the full text, without width limit
179 wid.size_hint = (None, None)
180 wid.texture_update()
181 full_width = wid._full_width = wid.texture_size[0]
182
183 if full_width > width:
184 wid.text_size = width, None
185 wid.width = width
186 else:
187 wid.text_size = None, None
188 wid.texture_update()
189 wid.width = wid.texture_size[0]
190 self.content_width = wid.width + self.padding[0] + self.padding[2]
191 else:
192 wid.size_hint = (1, None)
193 wid.height = 100
194 self.content_width = self.width
195 else:
196 self._do_complexe_sizing(width)
197
198 def _do_complexe_sizing(self, width):
199 try:
200 self.splitted
201 except AttributeError:
202 # XXX: to make things easier, we split labels in words
203 log.debug(u"split start")
204 children = self.children[::-1]
205 self.clear_widgets()
206 for child in children:
207 if isinstance(child, Label):
208 log.debug(u"label before split: {}".format(child.text))
209 styles = []
210 tag = False
211 new_text = []
212 current_tag = []
213 current_value = []
214 current_wid = self._createText()
215 value = False
216 close = False
217 # we will parse the text and create a new widget
218 # on each new word (actually each space)
219 # FIXME: handle '\n' and other white chars
220 for c in child.text:
221 if tag:
222 # we are parsing a markup tag
223 if c == u']':
224 current_tag_s = u''.join(current_tag)
225 current_style = (current_tag_s, u''.join(current_value))
226 if close:
227 for idx, s in enumerate(reversed(styles)):
228 if s[0] == current_tag_s:
229 del styles[len(styles) - idx - 1]
230 break
231 else:
232 styles.append(current_style)
233 current_tag = []
234 current_value = []
235 tag = False
236 value = False
237 close = False
238 elif c == u'/':
239 close = True
240 elif c == u'=':
241 value = True
242 elif value:
243 current_value.append(c)
244 else:
245 current_tag.append(c)
246 new_text.append(c)
247 else:
248 # we are parsing regular text
249 if c == u'[':
250 new_text.append(c)
251 tag = True
252 elif c == u' ':
253 # new word, we do a new widget
254 new_text.append(u' ')
255 for t, v in reversed(styles):
256 new_text.append(u'[/{}]'.format(t))
257 current_wid.text = u''.join(new_text)
258 new_text = []
259 self.add_widget(current_wid)
260 log.debug(u"new widget: {}".format(current_wid.text))
261 current_wid = self._createText()
262 for t, v in styles:
263 new_text.append(u'[{tag}{value}]'.format(
264 tag = t,
265 value = u'={}'.format(v) if v else u''))
266 else:
267 new_text.append(c)
268 if current_wid.text:
269 # we may have a remaining widget after the parsing
270 close_styles = []
271 for t, v in reversed(styles):
272 close_styles.append(u'[/{}]'.format(t))
273 current_wid.text = u''.join(close_styles)
274 self.add_widget(current_wid)
275 log.debug(u"new widget: {}".format(current_wid.text))
276 else:
277 # non Label widgets, we just add them
278 self.add_widget(child)
279 self.splitted = True
280 log.debug(u"split OK")
281
282 # we now set the content width
283 # FIXME: for now we just use the full width
284 self.content_width = width
285
286 # XHTML parsing methods
287
288 def _callParseMethod(self, e):
289 """call the suitable method to parse the element
290
291 self.xhtml_[tag] will be called if it exists, else
292 self.xhtml_generic will be used
293 @param e(ET.Element): element to parse
294 """
295 try:
296 method = getattr(self, "xhtml_{}".format(e.tag))
297 except AttributeError:
298 log.warning(u"Unhandled XHTML tag: {}".format(e.tag))
299 method = self.xhtml_generic
300 method(e)
301
302 def _addStyle(self, tag, value=None, append_to_list=True):
303 """add a markup style to label
304
305 @param tag(unicode): markup tag
306 @param value(unicode): markup value if suitable
307 @param append_to_list(bool): if True style we be added to self.styles
308 self.styles is needed to keep track of styles to remove
309 should most probably be set to True
310 """
311 label = self._getLabel()
312 label.text += u'[{tag}{value}]'.format(
313 tag = tag,
314 value = u'={}'.format(value) if value else ''
315 )
316 if append_to_list:
317 self.styles.append((tag, value))
318
319 def _removeStyle(self, tag, remove_from_list=True):
320 """remove a markup style from the label
321
322 @param tag(unicode): markup tag to remove
323 @param remove_from_list(bool): if True, remove from self.styles too
324 should most probably be set to True
325 """
326 label = self._getLabel()
327 label.text += u'[/{tag}]'.format(
328 tag = tag
329 )
330 if remove_from_list:
331 for rev_idx, style in enumerate(reversed(self.styles)):
332 if style[0] == tag:
333 tag_idx = len(self.styles) - 1 - rev_idx
334 del self.styles[tag_idx]
335 break
336
337 def _getLabel(self):
338 """get current Label if it exists, or create a new one"""
339 if not isinstance(self.current_wid, Label):
340 self._addLabel()
341 return self.current_wid
342
343 def _addLabel(self):
344 """add a new Label
345
346 current styles will be closed and reopened if needed
347 """
348 self._closeLabel()
349 self.current_wid = self._createText()
350 for tag, value in self.styles:
351 self._addStyle(tag, value, append_to_list=False)
352 self.add_widget(self.current_wid)
353
354 def _createText(self):
355 label = SimpleXHTMLWidgetText(color=self.color, markup=True)
356 self.bind(color=label.setter('color'))
357 label.bind(texture_size=label.setter('size'))
358 return label
359
360 def _closeLabel(self):
361 """close current style tags in current label
362
363 needed when you change label to keep style between
364 different widgets
365 """
366 if isinstance(self.current_wid, Label):
367 for tag, value in reversed(self.styles):
368 self._removeStyle(tag, remove_from_list=False)
369
370 def _parseCSS(self, e):
371 """parse CSS found in "style" attribute of element
372
373 self._css_styles will be created and contained markup styles added by this method
374 @param e(ET.Element): element which may have a "style" attribute
375 """
376 styles_limit = len(self.styles)
377 styles = e.attrib['style'].split(u';')
378 for style in styles:
379 try:
380 prop, value = style.split(u':')
381 except ValueError:
382 log.warning(u"can't parse style: {}".format(style))
383 continue
384 prop = prop.strip().replace(u'-', '_')
385 value = value.strip()
386 try:
387 method = getattr(self, "css_{}".format(prop))
388 except AttributeError:
389 log.warning(u"Unhandled CSS: {}".format(prop))
390 else:
391 method(e, value)
392 self._css_styles = self.styles[styles_limit:]
393
394 def _closeCSS(self):
395 """removed CSS styles
396
397 styles in self._css_styles will be removed
398 and the attribute will be deleted
399 """
400 for tag, dummy in reversed(self._css_styles):
401 self._removeStyle(tag)
402 del self._css_styles
403
404 def xhtml_generic(self, elem, style=True, markup=None):
405 """generic method for adding HTML elements
406
407 this method handle content, style and children parsing
408 @param elem(ET.Element): element to add
409 @param style(bool): if True handle style attribute (CSS)
410 @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
411 """
412 # we first add markup and CSS style
413 if markup is not None:
414 if isinstance(markup, basestring):
415 tag, value = markup, None
416 else:
417 tag, value = markup
418 self._addStyle(tag, value)
419 style_ = 'style' in elem.attrib and style
420 if style_:
421 self._parseCSS(elem)
422
423 # then content
424 if elem.text:
425 self._getLabel().text += escape_markup(elem.text)
426
427 # we parse the children
428 for child in elem:
429 self._callParseMethod(child)
430
431 # closing CSS style and markup
432 if style_:
433 self._closeCSS()
434 if markup is not None:
435 self._removeStyle(tag)
436
437 # and the tail, which is regular text
438 if elem.tail:
439 self._getLabel().text += escape_markup(elem.tail)
440
441 # method handling XHTML elements
442
443 def xhtml_br(self, elem):
444 label = self._getLabel()
445 label.text+='\n'
446 self.xhtml_generic(style=False)
447
448 def xhtml_em(self, elem):
449 self.xhtml_generic(elem, markup='i')
450
451 def xhtml_img(self, elem):
452 try:
453 src = elem.attrib['src']
454 except KeyError:
455 log.warning(u"<img> element without src: {}".format(ET.tostring(elem)))
456 return
457 try:
458 target_height = int(elem.get(u'height', 0))
459 except ValueError:
460 log.warning(u"Can't parse image height: {}".format(elem.get(u'height')))
461 target_height = 0
462 try:
463 target_width = int(elem.get(u'width', 0))
464 except ValueError:
465 log.warning(u"Can't parse image width: {}".format(elem.get(u'width')))
466 target_width = 0
467
468 img = SimpleXHTMLWidgetImage(source=src, target_height=target_height, target_width=target_width)
469 self.current_wid = img
470 self.add_widget(img)
471
472 def xhtml_p(self, elem):
473 if isinstance(self.current_wid, Label):
474 self.current_wid.text+="\n\n"
475 self.xhtml_generic(elem)
476
477 def xhtml_span(self, elem):
478 self.xhtml_generic(elem)
479
480 def xhtml_strong(self, elem):
481 self.xhtml_generic(elem, markup='b')
482
483 # methods handling CSS properties
484
485 def css_color(self, elem, value):
486 self._addStyle(u"color", css_color.parse(value))
487
488 def css_text_decoration(self, elem, value):
489 if value == u'underline':
490 log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value))
491 # FIXME: activate when 1.9.2 is out
492 # self._addStyle('u')
493 elif value == u'line-through':
494 log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value))
495 # FIXME: activate when 1.9.2 is out
496 # self._addStyle('s')
497 else:
498 log.warning(u"unhandled text decoration: {}".format(value))