diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/simple_xhtml.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program 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 Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from xml.etree import ElementTree as ET
+from kivy.uix.stacklayout import StackLayout
+from kivy.uix.label import Label
+from kivy.utils import escape_markup
+from kivy.metrics import sp
+from kivy import properties
+from libervia.backend.core import log as logging
+from libervia.frontends.tools import css_color, strings as sat_strings
+from libervia.desktop_kivy import G
+from libervia.desktop_kivy.core.common  import SizedImage
+
+
+log = logging.getLogger(__name__)
+
+
+class Escape(str):
+    """Class used to mark that a message need to be escaped"""
+
+
+class SimpleXHTMLWidgetEscapedText(Label):
+
+    def on_parent(self, instance, parent):
+        if parent is not None:
+            self.font_size = parent.font_size
+
+    def _add_url_markup(self, text):
+        text_elts = []
+        idx = 0
+        links = 0
+        while True:
+            m = sat_strings.RE_URL.search(text[idx:])
+            if m is not None:
+                text_elts.append(escape_markup(m.string[0:m.start()]))
+                link_key = 'link_' + str(links)
+                url = m.group()
+                escaped_url = escape_markup(url)
+                text_elts.append(
+                    f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]')
+                if not links:
+                    self.ref_urls = {link_key: url}
+                else:
+                    self.ref_urls[link_key] = url
+                links += 1
+                idx += m.end()
+            else:
+                if links:
+                    text_elts.append(escape_markup(text[idx:]))
+                    self.markup = True
+                    self.text = ''.join(text_elts)
+                break
+
+    def on_text(self, instance, text):
+        # do NOT call the method if self.markup is set
+        # this would result in infinite loop (because self.text
+        # is changed if an URL is found, and in this case markup too)
+        if text and not self.markup:
+            self._add_url_markup(text)
+
+    def on_ref_press(self, ref):
+        url = self.ref_urls[ref]
+        G.local_platform.open_url(url, self)
+
+
+class SimpleXHTMLWidgetText(Label):
+
+    def on_parent(self, instance, parent):
+        if parent is not None:
+            self.font_size = parent.font_size
+
+
+class SimpleXHTMLWidget(StackLayout):
+    """widget handling simple XHTML parsing"""
+    xhtml = properties.StringProperty()
+    color = properties.ListProperty([1, 1, 1, 1])
+    # XXX: bold is only used for escaped text
+    bold = properties.BooleanProperty(False)
+    font_size = properties.NumericProperty(sp(14))
+
+    # text/XHTML input
+
+    def on_xhtml(self, instance, xhtml):
+        """parse xhtml and set content accordingly
+
+        if xhtml is an instance of Escape, a Label with no markup will be used
+        """
+        self.clear_widgets()
+        if isinstance(xhtml, Escape):
+            label = SimpleXHTMLWidgetEscapedText(
+                text=xhtml, color=self.color, bold=self.bold)
+            self.bind(font_size=label.setter('font_size'))
+            self.bind(color=label.setter('color'))
+            self.bind(bold=label.setter('bold'))
+            self.add_widget(label)
+        else:
+            xhtml = ET.fromstring(xhtml.encode())
+            self.current_wid = None
+            self.styles = []
+            self._call_parse_method(xhtml)
+        if len(self.children) > 1:
+            self._do_split_labels()
+
+    def escape(self, text):
+        """mark that a text need to be escaped (i.e. no markup)"""
+        return Escape(text)
+
+    def _do_split_labels(self):
+        """Split labels so their content can flow with images"""
+        # XXX: to make things easier, we split labels in words
+        log.debug("labels splitting start")
+        children = self.children[::-1]
+        self.clear_widgets()
+        for child in children:
+            if isinstance(child, Label):
+                log.debug("label before split: {}".format(child.text))
+                styles = []
+                tag = False
+                new_text = []
+                current_tag = []
+                current_value = []
+                current_wid = self._create_text()
+                value = False
+                close = False
+                # we will parse the text and create a new widget
+                # on each new word (actually each space)
+                # FIXME: handle '\n' and other white chars
+                for c in child.text:
+                    if tag:
+                        # we are parsing a markup tag
+                        if c == ']':
+                            current_tag_s = ''.join(current_tag)
+                            current_style = (current_tag_s, ''.join(current_value))
+                            if close:
+                                for idx, s in enumerate(reversed(styles)):
+                                    if s[0] == current_tag_s:
+                                        del styles[len(styles) - idx - 1]
+                                        break
+                            else:
+                                styles.append(current_style)
+                            current_tag = []
+                            current_value = []
+                            tag = False
+                            value = False
+                            close = False
+                        elif c == '/':
+                            close = True
+                        elif c == '=':
+                            value = True
+                        elif value:
+                            current_value.append(c)
+                        else:
+                            current_tag.append(c)
+                        new_text.append(c)
+                    else:
+                        # we are parsing regular text
+                        if c == '[':
+                            new_text.append(c)
+                            tag = True
+                        elif c == ' ':
+                            # new word, we do a new widget
+                            new_text.append(' ')
+                            for t, v in reversed(styles):
+                                new_text.append('[/{}]'.format(t))
+                            current_wid.text = ''.join(new_text)
+                            new_text = []
+                            self.add_widget(current_wid)
+                            log.debug("new widget: {}".format(current_wid.text))
+                            current_wid = self._create_text()
+                            for t, v in styles:
+                                new_text.append('[{tag}{value}]'.format(
+                                    tag = t,
+                                    value = '={}'.format(v) if v else ''))
+                        else:
+                            new_text.append(c)
+                if current_wid.text:
+                    # we may have a remaining widget after the parsing
+                    close_styles = []
+                    for t, v in reversed(styles):
+                        close_styles.append('[/{}]'.format(t))
+                    current_wid.text = ''.join(close_styles)
+                    self.add_widget(current_wid)
+                    log.debug("new widget: {}".format(current_wid.text))
+            else:
+                # non Label widgets, we just add them
+                self.add_widget(child)
+        self.splitted = True
+        log.debug("split OK")
+
+    # XHTML parsing methods
+
+    def _call_parse_method(self, e):
+        """Call the suitable method to parse the element
+
+        self.xhtml_[tag] will be called if it exists, else
+        self.xhtml_generic will be used
+        @param e(ET.Element): element to parse
+        """
+        try:
+            method = getattr(self, f"xhtml_{e.tag}")
+        except AttributeError:
+            log.warning(f"Unhandled XHTML tag: {e.tag}")
+            method = self.xhtml_generic
+        method(e)
+
+    def _add_style(self, tag, value=None, append_to_list=True):
+        """add a markup style to label
+
+        @param tag(unicode): markup tag
+        @param value(unicode): markup value if suitable
+        @param append_to_list(bool): if True style we be added to self.styles
+            self.styles is needed to keep track of styles to remove
+            should most probably be set to True
+        """
+        label = self._get_label()
+        label.text += '[{tag}{value}]'.format(
+            tag = tag,
+            value = '={}'.format(value) if value else ''
+            )
+        if append_to_list:
+            self.styles.append((tag, value))
+
+    def _remove_style(self, tag, remove_from_list=True):
+        """remove a markup style from the label
+
+        @param tag(unicode): markup tag to remove
+        @param remove_from_list(bool): if True, remove from self.styles too
+            should most probably be set to True
+        """
+        label = self._get_label()
+        label.text += '[/{tag}]'.format(
+            tag = tag
+            )
+        if remove_from_list:
+            for rev_idx, style in enumerate(reversed(self.styles)):
+                if style[0] == tag:
+                    tag_idx = len(self.styles) - 1 - rev_idx
+                    del self.styles[tag_idx]
+                    break
+
+    def _get_label(self):
+        """get current Label if it exists, or create a new one"""
+        if not isinstance(self.current_wid, Label):
+            self._add_label()
+        return self.current_wid
+
+    def _add_label(self):
+        """add a new Label
+
+        current styles will be closed and reopened if needed
+        """
+        self._close_label()
+        self.current_wid = self._create_text()
+        for tag, value in self.styles:
+            self._add_style(tag, value, append_to_list=False)
+        self.add_widget(self.current_wid)
+
+    def _create_text(self):
+        label = SimpleXHTMLWidgetText(color=self.color, markup=True)
+        self.bind(color=label.setter('color'))
+        label.bind(texture_size=label.setter('size'))
+        return label
+
+    def _close_label(self):
+        """close current style tags in current label
+
+        needed when you change label to keep style between
+        different widgets
+        """
+        if isinstance(self.current_wid, Label):
+            for tag, value in reversed(self.styles):
+                self._remove_style(tag, remove_from_list=False)
+
+    def _parse_css(self, e):
+        """parse CSS found in "style" attribute of element
+
+        self._css_styles will be created and contained markup styles added by this method
+        @param e(ET.Element): element which may have a "style" attribute
+        """
+        styles_limit = len(self.styles)
+        styles = e.attrib['style'].split(';')
+        for style in styles:
+            try:
+                prop, value = style.split(':')
+            except ValueError:
+                log.warning(f"can't parse style: {style}")
+                continue
+            prop = prop.strip().replace('-', '_')
+            value = value.strip()
+            try:
+                method = getattr(self, f"css_{prop}")
+            except AttributeError:
+                log.warning(f"Unhandled CSS: {prop}")
+            else:
+                method(e, value)
+        self._css_styles = self.styles[styles_limit:]
+
+    def _close_css(self):
+        """removed CSS styles
+
+        styles in self._css_styles will be removed
+        and the attribute will be deleted
+        """
+        for tag, __ in reversed(self._css_styles):
+            self._remove_style(tag)
+        del self._css_styles
+
+    def xhtml_generic(self, elem, style=True, markup=None):
+        """Generic method for adding HTML elements
+
+        this method handle content, style and children parsing
+        @param elem(ET.Element): element to add
+        @param style(bool): if True handle style attribute (CSS)
+        @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
+        """
+        # we first add markup and CSS style
+        if markup is not None:
+            if isinstance(markup, str):
+                tag, value = markup, None
+            else:
+                tag, value = markup
+            self._add_style(tag, value)
+        style_ = 'style' in elem.attrib and style
+        if style_:
+            self._parse_css(elem)
+
+        # then content
+        if elem.text:
+            self._get_label().text += escape_markup(elem.text)
+
+        # we parse the children
+        for child in elem:
+            self._call_parse_method(child)
+
+        # closing CSS style and markup
+        if style_:
+            self._close_css()
+        if markup is not None:
+            self._remove_style(tag)
+
+        # and the tail, which is regular text
+        if elem.tail:
+            self._get_label().text += escape_markup(elem.tail)
+
+    # method handling XHTML elements
+
+    def xhtml_br(self, elem):
+        label = self._get_label()
+        label.text+='\n'
+        self.xhtml_generic(elem, style=False)
+
+    def xhtml_em(self, elem):
+        self.xhtml_generic(elem, markup='i')
+
+    def xhtml_img(self, elem):
+        try:
+            src = elem.attrib['src']
+        except KeyError:
+            log.warning("<img> element without src: {}".format(ET.tostring(elem)))
+            return
+        try:
+            target_height = int(elem.get('height', 0))
+        except ValueError:
+            log.warning(f"Can't parse image height: {elem.get('height')}")
+            target_height = None
+        try:
+            target_width = int(elem.get('width', 0))
+        except ValueError:
+            log.warning(f"Can't parse image width: {elem.get('width')}")
+            target_width = None
+
+        img = SizedImage(
+            source=src, target_height=target_height, target_width=target_width)
+        self.current_wid = img
+        self.add_widget(img)
+
+    def xhtml_p(self, elem):
+        if isinstance(self.current_wid, Label):
+            self.current_wid.text+="\n\n"
+        self.xhtml_generic(elem)
+
+    def xhtml_span(self, elem):
+        self.xhtml_generic(elem)
+
+    def xhtml_strong(self, elem):
+        self.xhtml_generic(elem, markup='b')
+
+    # methods handling CSS properties
+
+    def css_color(self, elem, value):
+        self._add_style("color", css_color.parse(value))
+
+    def css_text_decoration(self, elem, value):
+        if value == 'underline':
+            self._add_style('u')
+        elif value == 'line-through':
+            self._add_style('s')
+        else:
+            log.warning("unhandled text decoration: {}".format(value))