1import re
2import unicodedata
3from typing import Any
4
5from plain.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
6from plain.utils.regex_helper import _lazy_re_compile
7
8# Set up regular expressions
9_re_words = _lazy_re_compile(r"<[^>]+?>|([^<>\s]+)", re.DOTALL)
10_re_chars = _lazy_re_compile(r"<[^>]+?>|(.)", re.DOTALL)
11_re_tag = _lazy_re_compile(r"<(/)?(\S+?)(?:(\s*/)|\s.*?)?>", re.DOTALL)
12
13
14class Truncator(SimpleLazyObject):
15 """
16 An object used to truncate text, either by characters or words.
17 """
18
19 _wrapped: str # Override parent type since we always store str
20
21 def __init__(self, text: Any):
22 super().__init__(lambda: str(text))
23
24 def add_truncation_text(self, text: str, truncate: str | None = None) -> str:
25 if truncate is None:
26 truncate = "%(truncated_text)s…"
27 if "%(truncated_text)s" in truncate:
28 return truncate % {"truncated_text": text}
29 # The truncation text didn't contain the %(truncated_text)s string
30 # replacement argument so just append it to the text.
31 if text.endswith(truncate):
32 # But don't append the truncation text if the current text already
33 # ends in this.
34 return text
35 return f"{text}{truncate}"
36
37 def chars(self, num: int, truncate: str | None = None, html: bool = False) -> str:
38 """
39 Return the text truncated to be no longer than the specified number
40 of characters.
41
42 `truncate` specifies what should be used to notify that the string has
43 been truncated, defaulting to a translatable string of an ellipsis.
44 """
45 self._setup()
46 length = int(num)
47 text = unicodedata.normalize("NFC", self._wrapped)
48
49 # Calculate the length to truncate to (max length - end_text length)
50 truncate_len = length
51 for char in self.add_truncation_text("", truncate):
52 if not unicodedata.combining(char):
53 truncate_len -= 1
54 if truncate_len == 0:
55 break
56 if html:
57 return self._truncate_html(length, truncate, text, truncate_len, False)
58 return self._text_chars(length, truncate, text, truncate_len)
59
60 def _text_chars(
61 self, length: int, truncate: str | None, text: str, truncate_len: int
62 ) -> str:
63 """Truncate a string after a certain number of chars."""
64 s_len = 0
65 end_index = None
66 for i, char in enumerate(text):
67 if unicodedata.combining(char):
68 # Don't consider combining characters
69 # as adding to the string length
70 continue
71 s_len += 1
72 if end_index is None and s_len > truncate_len:
73 end_index = i
74 if s_len > length:
75 # Return the truncated string
76 return self.add_truncation_text(text[: end_index or 0], truncate)
77
78 # Return the original string since no truncation was necessary
79 return text
80
81 def words(self, num: int, truncate: str | None = None, html: bool = False) -> str:
82 """
83 Truncate a string after a certain number of words. `truncate` specifies
84 what should be used to notify that the string has been truncated,
85 defaulting to ellipsis.
86 """
87 self._setup()
88 length = int(num)
89 if html:
90 return self._truncate_html(length, truncate, self._wrapped, length, True)
91 return self._text_words(length, truncate)
92
93 def _text_words(self, length: int, truncate: str | None) -> str:
94 """
95 Truncate a string after a certain number of words.
96
97 Strip newlines in the string.
98 """
99 words = self._wrapped.split()
100 if len(words) > length:
101 words = words[:length]
102 return self.add_truncation_text(" ".join(words), truncate)
103 return " ".join(words)
104
105 def _truncate_html(
106 self,
107 length: int,
108 truncate: str | None,
109 text: str,
110 truncate_len: int,
111 words: bool,
112 ) -> str:
113 """
114 Truncate HTML to a certain number of chars (not counting tags and
115 comments), or, if words is True, then to a certain number of words.
116 Close opened tags if they were correctly closed in the given HTML.
117
118 Preserve newlines in the HTML.
119 """
120 if words and length <= 0:
121 return ""
122
123 html4_singlets = (
124 "br",
125 "col",
126 "link",
127 "base",
128 "img",
129 "param",
130 "area",
131 "hr",
132 "input",
133 )
134
135 # Count non-HTML chars/words and keep note of open tags
136 pos = 0
137 end_text_pos = 0
138 current_len = 0
139 open_tags = []
140
141 regex = _re_words if words else _re_chars
142
143 while current_len <= length:
144 m = regex.search(text, pos)
145 if not m:
146 # Checked through whole string
147 break
148 pos = m.end(0)
149 if m[1]:
150 # It's an actual non-HTML word or char
151 current_len += 1
152 if current_len == truncate_len:
153 end_text_pos = pos
154 continue
155 # Check for tag
156 tag = _re_tag.match(m[0])
157 if not tag or current_len >= truncate_len:
158 # Don't worry about non tags or tags after our truncate point
159 continue
160 closing_tag, tagname, self_closing = tag.groups()
161 # Element names are always case-insensitive
162 tagname = tagname.lower()
163 if self_closing or tagname in html4_singlets:
164 pass
165 elif closing_tag:
166 # Check for match in open tags list
167 try:
168 i = open_tags.index(tagname)
169 except ValueError:
170 pass
171 else:
172 # SGML: An end tag closes, back to the matching start tag,
173 # all unclosed intervening start tags with omitted end tags
174 open_tags = open_tags[i + 1 :]
175 else:
176 # Add it to the start of the open tags list
177 open_tags.insert(0, tagname)
178
179 if current_len <= length:
180 return text
181 out = text[:end_text_pos]
182 truncate_text = self.add_truncation_text("", truncate)
183 if truncate_text:
184 out += truncate_text
185 # Close any tags still open
186 for tag in open_tags:
187 out += f"</{tag}>"
188 # Return string
189 return out
190
191
192@keep_lazy_text
193def slugify(value: Any, allow_unicode: bool = False) -> str:
194 """
195 Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
196 dashes to single dashes. Remove characters that aren't alphanumerics,
197 underscores, or hyphens. Convert to lowercase. Also strip leading and
198 trailing whitespace, dashes, and underscores.
199 """
200 value = str(value)
201 if allow_unicode:
202 value = unicodedata.normalize("NFKC", value)
203 else:
204 value = (
205 unicodedata.normalize("NFKD", value)
206 .encode("ascii", "ignore")
207 .decode("ascii")
208 )
209 value = re.sub(r"[^\w\s-]", "", value.lower())
210 return re.sub(r"[-\s]+", "-", value).strip("-_")
211
212
213def pluralize(singular: str, plural: str, number: int) -> str:
214 if number == 1:
215 return singular
216 else:
217 return plural
218
219
220def pluralize_lazy(singular: str, plural: str, number: int | str) -> Any:
221 def _lazy_number_unpickle(
222 func: Any, resultclass: Any, number: Any, kwargs: dict[str, Any]
223 ) -> Any:
224 return lazy_number(func, resultclass, number=number, **kwargs)
225
226 def lazy_number(
227 func: Any, resultclass: Any, number: int | str | None = None, **kwargs: Any
228 ) -> Any:
229 if isinstance(number, int):
230 kwargs["number"] = number
231 proxy = lazy(func, resultclass)(**kwargs)
232 else:
233 original_kwargs = kwargs.copy()
234
235 class NumberAwareString(resultclass):
236 def __bool__(self) -> bool:
237 return bool(kwargs["singular"])
238
239 def _get_number_value(self, values: dict[str, Any]) -> Any:
240 try:
241 return values[number] # ty: ignore[invalid-argument-type]
242 except KeyError:
243 raise KeyError(
244 f"Your dictionary lacks key '{number}'. Please provide "
245 "it, because it is required to determine whether "
246 "string is singular or plural."
247 )
248
249 def _translate(self, number_value: int) -> str:
250 kwargs["number"] = number_value
251 return func(**kwargs)
252
253 def format(self, *args: Any, **kwargs: Any) -> str:
254 number_value = (
255 self._get_number_value(kwargs) if kwargs and number else args[0]
256 )
257 return self._translate(number_value).format(*args, **kwargs)
258
259 def __mod__(self, rhs: Any) -> str:
260 if isinstance(rhs, dict) and number:
261 number_value = self._get_number_value(rhs)
262 else:
263 number_value = rhs
264 translated = self._translate(number_value)
265 try:
266 translated %= rhs
267 except TypeError:
268 # String doesn't contain a placeholder for the number.
269 pass
270 return translated
271
272 proxy = lazy(lambda **kwargs: NumberAwareString(), NumberAwareString)(
273 **kwargs
274 )
275 proxy.__reduce__ = lambda: (
276 _lazy_number_unpickle,
277 (func, resultclass, number, original_kwargs),
278 )
279 return proxy
280
281 return lazy_number(pluralize, str, singular=singular, plural=plural, number=number)