1#
2# Gramps - a GTK+/GNOME based genealogy program
3#
4# Copyright (C) 2008       Zsolt Foldvari
5# Copyright (C) 2013       Doug Blank <doug.blank@gmail.com>
6# Copyright (C) 2017       Nick Hall
7#
8# This program is free software; you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation; either version 2 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with this program; if not, write to the Free Software
20# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21#
22
23"Handling formatted ('rich text') strings"
24
25#-------------------------------------------------------------------------
26#
27# Gramps modules
28#
29#-------------------------------------------------------------------------
30from copy import copy
31from .styledtexttag import StyledTextTag
32from ..const import GRAMPS_LOCALE as glocale
33_ = glocale.translation.gettext
34
35#-------------------------------------------------------------------------
36#
37# StyledText class
38#
39#-------------------------------------------------------------------------
40class StyledText:
41    """Helper class to enable character based text formatting.
42
43    :py:class:`StyledText` is a wrapper class binding the clear text string and
44    it's formatting tags together.
45
46    :py:class:`StyledText` provides several string methods in order to
47    manipulate formatted strings, such as :py:meth:`join`, :py:meth:`replace`,
48    :py:meth:`split`, and also supports the '+' operation (:py:meth:`__add__`).
49
50    To get the clear text of the :py:class:`StyledText` use the built-in
51    :py:func:`str()` function. To get the list of formatting tags use the
52    :py:meth:`get_tags` method.
53
54    StyledText supports the *creation* of formatted texts too. This feature
55    is intended to replace (or extend) the current report interface.
56    To be continued... FIXME
57
58    :ivar string: (str) The clear text part.
59    :ivar tags: (list of :py:class:`.StyledTextTag`) Text tags holding
60                formatting information for the string.
61
62    :cvar POS_TEXT: Position of *string* attribute in the serialized format of
63                    an instance.
64    :cvar POS_TAGS: (int) Position of *tags* attribute in the serialized format
65                    of an instance.
66
67    .. warning:: The POS_<x> class variables reflect the serialized object,
68      they have to be updated in case the data structure or the
69      :py:meth:`serialize` method changes!
70
71    .. note::
72     1. There is no sanity check of tags in :py:meth:`__init__`, because when a
73        :py:class:`StyledText` is displayed it is passed to a
74        :py:class:`.StyledTextBuffer`, which in turn will 'eat' all invalid
75        tags (including out-of-range tags too).
76     2. After string methods the tags can become fragmented. That means the same
77        tag may appear more than once in the tag list with different ranges.
78        There could be a 'merge_tags' functionality in :py:meth:`__init__`,
79        however :py:class:`StyledTextBuffer` will merge them automatically if
80        the text is displayed.
81     3. Warning: Some of these operations modify the source tag ranges in place
82        so if you intend to use a source tag more than once, copy it for use.
83    """
84    (POS_TEXT, POS_TAGS) = list(range(2))
85
86    def __init__(self, text="", tags=None):
87        """Setup initial instance variable values."""
88        self._string = text
89
90        if tags:
91            self._tags = tags
92        else:
93            self._tags = []
94
95    # special methods
96
97    def __str__(self):
98        return self._string.__str__()
99
100    def __repr__(self):
101        return self._string.__repr__()
102
103    def __add__(self, other):
104        """Implement '+' operation on the class.
105
106        :param other: string to concatenate to self
107        :type other: basestring or :py:class:`StyledText`
108        :return: concatenated strings
109        :rtype: :py:class:`StyledText`
110
111        """
112        offset = len(self._string)
113
114        if isinstance(other, StyledText):
115            # need to join strings and merge tags
116            for tag in other.tags:
117                tag.ranges = [(start + offset, end + offset)
118                              for (start, end) in tag.ranges]
119
120            return self.__class__("".join([self._string, other.string]),
121                                  self._tags + other.tags)
122        elif isinstance(other, str):
123            # tags remain the same, only text becomes longer
124            return self.__class__("".join([self._string, other]), self._tags)
125        else:
126            return self.__class__("".join([self._string, str(other)]),
127                                  self._tags)
128
129    def __eq__(self, other):
130        return self._string == other.string and self._tags == other.tags
131
132    def __ne__(self, other):
133        return self._string != other.string or self._tags != other.tags
134
135    def __lt__(self, other):
136        return self._string < other.string
137
138    def __le__(self, other):
139        return self.__lt__(other) or self.__eq__(other)
140
141    def __gt__(self, other):
142        return not self.__le__(other)
143
144    def __ge__(self, other):
145        return self.__gt__(other) or self.__eq__(other)
146
147    def __mod__(self, other):
148        """Implement '%' operation on the class."""
149
150        # This will raise an exception if the formatting operation is invalid
151        self._string % other
152
153        result = self.__class__(self._string, self._tags)
154
155        start = 0
156        while True:
157            idx1 = result.string.find('%', start)
158            if idx1 < 0:
159                break
160            if result.string[idx1+1] == '(':
161                idx2 = result.string.find(')', idx1+3)
162                param_name = result.string[idx1+2:idx2]
163            else:
164                idx2 = idx1
165                param_name = None
166            end = idx2 + 1
167            for end in range(idx2+1, len(result.string)):
168                if result.string[end] in 'diouxXeEfFgGcrs%':
169                    break
170            if param_name is not None:
171                param = other[param_name]
172            elif isinstance(other, tuple):
173                param = other[0]
174                other = other[1:]
175            else:
176                param = other
177            if not isinstance(param, StyledText):
178                param_type = '%' + result.string[idx2+1:end+1]
179                param = StyledText(param_type % param)
180            parts = result.split(result.string[idx1:end+1], 1)
181            if len(parts) == 2:
182                result = parts[0] + param + parts[1]
183            start = end + 1
184
185        return result
186
187    # private methods
188
189
190    # string methods in alphabetical order:
191
192    def join(self, seq):
193        """
194        Emulate :py:meth:`__builtin__.str.join` method.
195
196        :param seq: list of strings to join
197        :type seq: basestring or :py:class:`StyledText`
198        :return: joined strings
199        :rtype: :py:class:`StyledText`
200        """
201        new_string = self._string.join([str(string) for string in seq])
202
203        offset = 0
204        not_first = False
205        new_tags = []
206        self_len = len(self._string)
207
208        for text in seq:
209            if not_first:  # if not first time through...
210                # put the joined element tag(s) into place
211                for tag in self.tags:
212                    ntag = copy(tag)
213                    ntag.ranges = [(start + offset, end + offset)
214                                   for (start, end) in tag.ranges]
215                    new_tags += [ntag]
216                offset += self_len
217            if isinstance(text, StyledText):
218                for tag in text.tags:
219                    ntag = copy(tag)
220                    ntag.ranges = [(start + offset, end + offset)
221                                   for (start, end) in tag.ranges]
222                    new_tags += [ntag]
223            offset += len(str(text))
224            not_first = True
225
226        return self.__class__(new_string, new_tags)
227
228    def replace(self, old, new, count=-1):
229        """
230        Emulate :py:meth:`__builtin__.str.replace` method.
231
232        :param old: substring to be replaced
233        :type old: basestring or :py:class:`StyledText`
234        :param new: substring to replace by
235        :type new: :py:class:`StyledText`
236        :param count: if given, only the first count occurrences are replaced
237        :type count: int
238        :return: copy of the string with replaced substring(s)
239        :rtype: :py:class:`StyledText`
240
241        .. warning:: by the correct implementation parameter *new* should be
242                     :py:class:`StyledText` or basestring, however only
243                     :py:class:`StyledText` is currently supported.
244        """
245        # quick and dirty solution: works only if new.__class__ == StyledText
246        return new.join(self.split(old, count))
247
248    def split(self, sep=None, maxsplit=-1):
249        """
250        Emulate :py:meth:`__builtin__.str.split` method.
251
252        :param sep: the delimiter string
253        :type seq: basestring or :py:class:`StyledText`
254        :param maxsplit: if given, at most maxsplit splits are done
255        :type maxsplit: int
256        :return: a list of the words in the string
257        :rtype: list of :py:class:`StyledText`
258        """
259        # split the clear text first
260        if sep is not None:
261            sep = str(sep)
262        string_list = self._string.split(sep, maxsplit)
263
264        # then split the tags too
265        end_string = 0
266        styledtext_list = []
267
268        for string in string_list:
269            start_string = self._string.find(string, end_string)
270            end_string = start_string + len(string)
271
272            new_tags = []
273
274            for tag in self._tags:
275                new_tag = StyledTextTag(int(tag.name), tag.value)
276                for (start_tag, end_tag) in tag.ranges:
277                    start = max(start_string, start_tag)
278                    end = min(end_string, end_tag)
279
280                    if start < end:
281                        new_tag.ranges.append((start - start_string,
282                                               end - start_string))
283
284                if new_tag.ranges:
285                    new_tags.append(new_tag)
286
287            styledtext_list.append(self.__class__(string, new_tags))
288
289        return styledtext_list
290
291    # other public methods
292
293    def serialize(self):
294        """
295        Convert the object to a serialized tuple of data.
296
297        :return: Serialized format of the instance.
298        :rtype: tuple
299        """
300        if self._tags:
301            the_tags = [tag.serialize() for tag in self._tags]
302            the_tags.sort()
303        else:
304            the_tags = []
305
306        return (self._string, the_tags)
307
308    @classmethod
309    def get_schema(cls):
310        """
311        Returns the JSON Schema for this class.
312
313        :returns: Returns a dict containing the schema.
314        :rtype: dict
315        """
316        return {
317            "type": "object",
318            "title": _("Styled Text"),
319            "properties": {
320                "_class": {"enum": [cls.__name__]},
321                "string": {"type": "string",
322                           "title": _("Text")},
323                "tags": {"type": "array",
324                         "items": StyledTextTag.get_schema(),
325                         "title": _("Styled Text Tags")}
326            }
327        }
328
329    def unserialize(self, data):
330        """
331        Convert a serialized tuple of data to an object.
332
333        :param data: Serialized format of instance variables.
334        :type data: tuple
335        """
336        (self._string, the_tags) = data
337
338        # I really wonder why this doesn't work... it does for all other types
339        #self._tags = [StyledTextTag().unserialize(tag) for tag in the_tags]
340        for tag in the_tags:
341            stt = StyledTextTag()
342            stt.unserialize(tag)
343            self._tags.append(stt)
344        return self
345
346    def get_tags(self):
347        """
348        Return the list of formatting tags.
349
350        :return: The formatting tags applied on the text.
351        :rtype: list of 0 or more :py:class:`.StyledTextTag` instances.
352        """
353        return self._tags
354
355    def set_tags(self, tags):
356        """
357        Set the list of formatting tags.
358
359        :param tags: The formatting tags applied on the text.
360        :type tags: list of 0 or more :py:class:`.StyledTextTag` instances.
361        """
362        self._tags = tags
363
364    def get_string(self):
365        """
366        Accessor for the associated string.
367        """
368        return self._string
369
370    def set_string(self, string):
371        """
372        Setter for the associated string.
373        """
374        self._string = string
375
376    tags = property(get_tags, set_tags)
377    string = property(get_string, set_string)
378
379if __name__ == '__main__':
380    from .styledtexttagtype import StyledTextTagType
381    T1 = StyledTextTag(StyledTextTagType(1), 'v1', [(0, 2), (2, 4), (4, 6)])
382    T2 = StyledTextTag(StyledTextTagType(2), 'v2', [(1, 3), (3, 5), (0, 7)])
383    T3 = StyledTextTag(StyledTextTagType(0), 'v3', [(0, 1)])
384
385    A = StyledText('123X456', [T1])
386    B = StyledText("abcXdef", [T2])
387
388    C = StyledText('\n')
389
390    S = 'cleartext'
391
392    C = C.join([A, S, B])
393    L = C.split()
394    C = C.replace('X', StyledText('_', [T3]))
395    A = A + B
396
397    print(A)
398