comparison libervia/desktop_kivy/core/simple_xhtml.py @ 493:b3cedbee561d

refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 18:26:16 +0200
parents cagou/core/simple_xhtml.py@203755bbe0fe
children
comparison
equal deleted inserted replaced
492:5114bbb5daa3 493:b3cedbee561d
1 #!/usr/bin/env python3
2
3
4 #Libervia Desktop-Kivy
5 # Copyright (C) 2016-2021 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 xml.etree import ElementTree as ET
22 from kivy.uix.stacklayout import StackLayout
23 from kivy.uix.label import Label
24 from kivy.utils import escape_markup
25 from kivy.metrics import sp
26 from kivy import properties
27 from libervia.backend.core import log as logging
28 from libervia.frontends.tools import css_color, strings as sat_strings
29 from libervia.desktop_kivy import G
30 from libervia.desktop_kivy.core.common import SizedImage
31
32
33 log = logging.getLogger(__name__)
34
35
36 class Escape(str):
37 """Class used to mark that a message need to be escaped"""
38
39
40 class SimpleXHTMLWidgetEscapedText(Label):
41
42 def on_parent(self, instance, parent):
43 if parent is not None:
44 self.font_size = parent.font_size
45
46 def _add_url_markup(self, text):
47 text_elts = []
48 idx = 0
49 links = 0
50 while True:
51 m = sat_strings.RE_URL.search(text[idx:])
52 if m is not None:
53 text_elts.append(escape_markup(m.string[0:m.start()]))
54 link_key = 'link_' + str(links)
55 url = m.group()
56 escaped_url = escape_markup(url)
57 text_elts.append(
58 f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]')
59 if not links:
60 self.ref_urls = {link_key: url}
61 else:
62 self.ref_urls[link_key] = url
63 links += 1
64 idx += m.end()
65 else:
66 if links:
67 text_elts.append(escape_markup(text[idx:]))
68 self.markup = True
69 self.text = ''.join(text_elts)
70 break
71
72 def on_text(self, instance, text):
73 # do NOT call the method if self.markup is set
74 # this would result in infinite loop (because self.text
75 # is changed if an URL is found, and in this case markup too)
76 if text and not self.markup:
77 self._add_url_markup(text)
78
79 def on_ref_press(self, ref):
80 url = self.ref_urls[ref]
81 G.local_platform.open_url(url, self)
82
83
84 class SimpleXHTMLWidgetText(Label):
85
86 def on_parent(self, instance, parent):
87 if parent is not None:
88 self.font_size = parent.font_size
89
90
91 class SimpleXHTMLWidget(StackLayout):
92 """widget handling simple XHTML parsing"""
93 xhtml = properties.StringProperty()
94 color = properties.ListProperty([1, 1, 1, 1])
95 # XXX: bold is only used for escaped text
96 bold = properties.BooleanProperty(False)
97 font_size = properties.NumericProperty(sp(14))
98
99 # text/XHTML input
100
101 def on_xhtml(self, instance, xhtml):
102 """parse xhtml and set content accordingly
103
104 if xhtml is an instance of Escape, a Label with no markup will be used
105 """
106 self.clear_widgets()
107 if isinstance(xhtml, Escape):
108 label = SimpleXHTMLWidgetEscapedText(
109 text=xhtml, color=self.color, bold=self.bold)
110 self.bind(font_size=label.setter('font_size'))
111 self.bind(color=label.setter('color'))
112 self.bind(bold=label.setter('bold'))
113 self.add_widget(label)
114 else:
115 xhtml = ET.fromstring(xhtml.encode())
116 self.current_wid = None
117 self.styles = []
118 self._call_parse_method(xhtml)
119 if len(self.children) > 1:
120 self._do_split_labels()
121
122 def escape(self, text):
123 """mark that a text need to be escaped (i.e. no markup)"""
124 return Escape(text)
125
126 def _do_split_labels(self):
127 """Split labels so their content can flow with images"""
128 # XXX: to make things easier, we split labels in words
129 log.debug("labels splitting start")
130 children = self.children[::-1]
131 self.clear_widgets()
132 for child in children:
133 if isinstance(child, Label):
134 log.debug("label before split: {}".format(child.text))
135 styles = []
136 tag = False
137 new_text = []
138 current_tag = []
139 current_value = []
140 current_wid = self._create_text()
141 value = False
142 close = False
143 # we will parse the text and create a new widget
144 # on each new word (actually each space)
145 # FIXME: handle '\n' and other white chars
146 for c in child.text:
147 if tag:
148 # we are parsing a markup tag
149 if c == ']':
150 current_tag_s = ''.join(current_tag)
151 current_style = (current_tag_s, ''.join(current_value))
152 if close:
153 for idx, s in enumerate(reversed(styles)):
154 if s[0] == current_tag_s:
155 del styles[len(styles) - idx - 1]
156 break
157 else:
158 styles.append(current_style)
159 current_tag = []
160 current_value = []
161 tag = False
162 value = False
163 close = False
164 elif c == '/':
165 close = True
166 elif c == '=':
167 value = True
168 elif value:
169 current_value.append(c)
170 else:
171 current_tag.append(c)
172 new_text.append(c)
173 else:
174 # we are parsing regular text
175 if c == '[':
176 new_text.append(c)
177 tag = True
178 elif c == ' ':
179 # new word, we do a new widget
180 new_text.append(' ')
181 for t, v in reversed(styles):
182 new_text.append('[/{}]'.format(t))
183 current_wid.text = ''.join(new_text)
184 new_text = []
185 self.add_widget(current_wid)
186 log.debug("new widget: {}".format(current_wid.text))
187 current_wid = self._create_text()
188 for t, v in styles:
189 new_text.append('[{tag}{value}]'.format(
190 tag = t,
191 value = '={}'.format(v) if v else ''))
192 else:
193 new_text.append(c)
194 if current_wid.text:
195 # we may have a remaining widget after the parsing
196 close_styles = []
197 for t, v in reversed(styles):
198 close_styles.append('[/{}]'.format(t))
199 current_wid.text = ''.join(close_styles)
200 self.add_widget(current_wid)
201 log.debug("new widget: {}".format(current_wid.text))
202 else:
203 # non Label widgets, we just add them
204 self.add_widget(child)
205 self.splitted = True
206 log.debug("split OK")
207
208 # XHTML parsing methods
209
210 def _call_parse_method(self, e):
211 """Call the suitable method to parse the element
212
213 self.xhtml_[tag] will be called if it exists, else
214 self.xhtml_generic will be used
215 @param e(ET.Element): element to parse
216 """
217 try:
218 method = getattr(self, f"xhtml_{e.tag}")
219 except AttributeError:
220 log.warning(f"Unhandled XHTML tag: {e.tag}")
221 method = self.xhtml_generic
222 method(e)
223
224 def _add_style(self, tag, value=None, append_to_list=True):
225 """add a markup style to label
226
227 @param tag(unicode): markup tag
228 @param value(unicode): markup value if suitable
229 @param append_to_list(bool): if True style we be added to self.styles
230 self.styles is needed to keep track of styles to remove
231 should most probably be set to True
232 """
233 label = self._get_label()
234 label.text += '[{tag}{value}]'.format(
235 tag = tag,
236 value = '={}'.format(value) if value else ''
237 )
238 if append_to_list:
239 self.styles.append((tag, value))
240
241 def _remove_style(self, tag, remove_from_list=True):
242 """remove a markup style from the label
243
244 @param tag(unicode): markup tag to remove
245 @param remove_from_list(bool): if True, remove from self.styles too
246 should most probably be set to True
247 """
248 label = self._get_label()
249 label.text += '[/{tag}]'.format(
250 tag = tag
251 )
252 if remove_from_list:
253 for rev_idx, style in enumerate(reversed(self.styles)):
254 if style[0] == tag:
255 tag_idx = len(self.styles) - 1 - rev_idx
256 del self.styles[tag_idx]
257 break
258
259 def _get_label(self):
260 """get current Label if it exists, or create a new one"""
261 if not isinstance(self.current_wid, Label):
262 self._add_label()
263 return self.current_wid
264
265 def _add_label(self):
266 """add a new Label
267
268 current styles will be closed and reopened if needed
269 """
270 self._close_label()
271 self.current_wid = self._create_text()
272 for tag, value in self.styles:
273 self._add_style(tag, value, append_to_list=False)
274 self.add_widget(self.current_wid)
275
276 def _create_text(self):
277 label = SimpleXHTMLWidgetText(color=self.color, markup=True)
278 self.bind(color=label.setter('color'))
279 label.bind(texture_size=label.setter('size'))
280 return label
281
282 def _close_label(self):
283 """close current style tags in current label
284
285 needed when you change label to keep style between
286 different widgets
287 """
288 if isinstance(self.current_wid, Label):
289 for tag, value in reversed(self.styles):
290 self._remove_style(tag, remove_from_list=False)
291
292 def _parse_css(self, e):
293 """parse CSS found in "style" attribute of element
294
295 self._css_styles will be created and contained markup styles added by this method
296 @param e(ET.Element): element which may have a "style" attribute
297 """
298 styles_limit = len(self.styles)
299 styles = e.attrib['style'].split(';')
300 for style in styles:
301 try:
302 prop, value = style.split(':')
303 except ValueError:
304 log.warning(f"can't parse style: {style}")
305 continue
306 prop = prop.strip().replace('-', '_')
307 value = value.strip()
308 try:
309 method = getattr(self, f"css_{prop}")
310 except AttributeError:
311 log.warning(f"Unhandled CSS: {prop}")
312 else:
313 method(e, value)
314 self._css_styles = self.styles[styles_limit:]
315
316 def _close_css(self):
317 """removed CSS styles
318
319 styles in self._css_styles will be removed
320 and the attribute will be deleted
321 """
322 for tag, __ in reversed(self._css_styles):
323 self._remove_style(tag)
324 del self._css_styles
325
326 def xhtml_generic(self, elem, style=True, markup=None):
327 """Generic method for adding HTML elements
328
329 this method handle content, style and children parsing
330 @param elem(ET.Element): element to add
331 @param style(bool): if True handle style attribute (CSS)
332 @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
333 """
334 # we first add markup and CSS style
335 if markup is not None:
336 if isinstance(markup, str):
337 tag, value = markup, None
338 else:
339 tag, value = markup
340 self._add_style(tag, value)
341 style_ = 'style' in elem.attrib and style
342 if style_:
343 self._parse_css(elem)
344
345 # then content
346 if elem.text:
347 self._get_label().text += escape_markup(elem.text)
348
349 # we parse the children
350 for child in elem:
351 self._call_parse_method(child)
352
353 # closing CSS style and markup
354 if style_:
355 self._close_css()
356 if markup is not None:
357 self._remove_style(tag)
358
359 # and the tail, which is regular text
360 if elem.tail:
361 self._get_label().text += escape_markup(elem.tail)
362
363 # method handling XHTML elements
364
365 def xhtml_br(self, elem):
366 label = self._get_label()
367 label.text+='\n'
368 self.xhtml_generic(elem, style=False)
369
370 def xhtml_em(self, elem):
371 self.xhtml_generic(elem, markup='i')
372
373 def xhtml_img(self, elem):
374 try:
375 src = elem.attrib['src']
376 except KeyError:
377 log.warning("<img> element without src: {}".format(ET.tostring(elem)))
378 return
379 try:
380 target_height = int(elem.get('height', 0))
381 except ValueError:
382 log.warning(f"Can't parse image height: {elem.get('height')}")
383 target_height = None
384 try:
385 target_width = int(elem.get('width', 0))
386 except ValueError:
387 log.warning(f"Can't parse image width: {elem.get('width')}")
388 target_width = None
389
390 img = SizedImage(
391 source=src, target_height=target_height, target_width=target_width)
392 self.current_wid = img
393 self.add_widget(img)
394
395 def xhtml_p(self, elem):
396 if isinstance(self.current_wid, Label):
397 self.current_wid.text+="\n\n"
398 self.xhtml_generic(elem)
399
400 def xhtml_span(self, elem):
401 self.xhtml_generic(elem)
402
403 def xhtml_strong(self, elem):
404 self.xhtml_generic(elem, markup='b')
405
406 # methods handling CSS properties
407
408 def css_color(self, elem, value):
409 self._add_style("color", css_color.parse(value))
410
411 def css_text_decoration(self, elem, value):
412 if value == 'underline':
413 self._add_style('u')
414 elif value == 'line-through':
415 self._add_style('s')
416 else:
417 log.warning("unhandled text decoration: {}".format(value))