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