nativelayout 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,326 @@
1
+ """HTML parser of the core (port of HtmlParser.kt)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import threading
6
+
7
+ from .errors import NativeLayoutError, extract_line
8
+ from .tree import Native, NativeStyle
9
+
10
+ _WHITESPACE = re.compile(r"\s+")
11
+ _CLASS_CACHE: dict[str, list[str]] = {}
12
+ _CLASS_LOCK = threading.Lock()
13
+ _HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
14
+ _DEC_RE = re.compile(r"^[0-9]+$")
15
+ VOID_TAGS = frozenset({"br", "hr", "img", "input", "meta", "link"})
16
+
17
+
18
+ class MarkupError(NativeLayoutError):
19
+ def __init__(self, message: str, line: int, source: str,
20
+ word: str | None = None, suggestion: str | None = None):
21
+ super().__init__(f"markup: {message}", line, extract_line(source, line), word, suggestion)
22
+
23
+
24
+ class HtmlNode:
25
+ line: int
26
+
27
+
28
+ class HtmlText(HtmlNode):
29
+ def __init__(self, text: str, line: int):
30
+ self.text = text
31
+ self.line = line
32
+
33
+
34
+ class HtmlElement(HtmlNode):
35
+ def __init__(self, tag: str, attributes: dict[str, str], children: list[HtmlNode], line: int):
36
+ self.tag = tag
37
+ self.attributes = attributes
38
+ self.children = children
39
+ self.line = line
40
+ self.chunk_text: str | None = None
41
+
42
+ @property
43
+ def id(self) -> str | None:
44
+ v = self.attributes.get("id")
45
+ if v is None:
46
+ return None
47
+ v = v.strip()
48
+ return v or None
49
+
50
+ @property
51
+ def classes(self) -> list[str]:
52
+ raw = self.attributes.get("class")
53
+ if raw is None:
54
+ return []
55
+ with _CLASS_LOCK:
56
+ hit = _CLASS_CACHE.get(raw)
57
+ if hit is not None:
58
+ return hit
59
+ parsed = [p for p in _WHITESPACE.split(raw) if p]
60
+ if len(_CLASS_CACHE) > 1024:
61
+ _CLASS_CACHE.clear()
62
+ _CLASS_CACHE[raw] = parsed
63
+ return parsed
64
+
65
+
66
+ class Chunk:
67
+ """An element with a key whose text did not change: it is not parsed again."""
68
+ def __init__(self, text: str, element: HtmlElement, native: Native,
69
+ parent_style: NativeStyle, ids: list[tuple[str, Native]]):
70
+ self.text = text
71
+ self.element = element
72
+ self.native = native
73
+ self.parent_style = parent_style
74
+ self.ids = ids
75
+
76
+
77
+ class HtmlCachedElement(HtmlElement):
78
+ def __init__(self, chunk: Chunk, line: int):
79
+ super().__init__(chunk.element.tag, chunk.element.attributes,
80
+ chunk.element.children, line)
81
+ self.chunk = chunk
82
+
83
+
84
+ class ChunkCache:
85
+ def __init__(self):
86
+ self._map: dict[str, Chunk] = {}
87
+ self._variables: dict[str, str] | None = None
88
+ self._lock = threading.RLock()
89
+
90
+ def use_for(self, variables: dict[str, str]) -> None:
91
+ with self._lock:
92
+ if self._variables != variables:
93
+ self._map.clear()
94
+ self._variables = variables
95
+
96
+ def get(self, key: str) -> Chunk | None:
97
+ with self._lock:
98
+ return self._map.get(key)
99
+
100
+ def put(self, key: str, chunk: Chunk) -> None:
101
+ with self._lock:
102
+ if len(self._map) >= 20000:
103
+ self._map.clear()
104
+ self._map[key] = chunk
105
+
106
+ def clear(self) -> None:
107
+ with self._lock:
108
+ self._map.clear()
109
+
110
+
111
+ class _Open:
112
+ def __init__(self, tag: str, attributes: dict[str, str], line: int, start: int):
113
+ self.tag = tag
114
+ self.attributes = attributes
115
+ self.line = line
116
+ self.start = start
117
+ self.children: list[HtmlNode] = []
118
+
119
+
120
+ def _key_of(attributes: dict[str, str]) -> str | None:
121
+ v = attributes.get("key")
122
+ if v is None:
123
+ return None
124
+ v = v.strip()
125
+ return v or None
126
+
127
+
128
+ class HtmlParser:
129
+ def __init__(self, html: str, start_line: int, source: str,
130
+ chunks: ChunkCache | None = None):
131
+ self.html = html
132
+ self.source = source
133
+ self.chunks = chunks
134
+ self.i = 0
135
+ self.line = start_line
136
+ self.stack: list[_Open] = []
137
+ self.root: list[HtmlNode] = []
138
+
139
+ def _err(self, message: str, at: int | None = None, word: str | None = None):
140
+ return MarkupError(message, self.line if at is None else at, self.source, word)
141
+
142
+ def _target(self) -> list[HtmlNode]:
143
+ return self.root if not self.stack else self.stack[-1].children
144
+
145
+ def _advance_to(self, end: int) -> None:
146
+ self.line += self.html.count("\n", self.i, end)
147
+ self.i = end
148
+
149
+ def parse(self) -> list[HtmlNode]:
150
+ html = self.html
151
+ n = len(html)
152
+ while self.i < n:
153
+ i = self.i
154
+ c = html[i]
155
+ if html.startswith("<!--", i):
156
+ end = html.find("-->", i + 4)
157
+ if end < 0:
158
+ raise self._err("unclosed HTML comment: missing '-->'")
159
+ self._advance_to(end + 3)
160
+ elif html.startswith("<!", i):
161
+ end = html.find(">", i)
162
+ if end < 0:
163
+ raise self._err("unclosed '<!': missing '>'")
164
+ self._advance_to(end + 1)
165
+ elif html.startswith("</", i):
166
+ self._read_close()
167
+ elif c == "<" and i + 1 < n and html[i + 1].isalpha():
168
+ self._read_open()
169
+ else:
170
+ self._read_text()
171
+ if self.stack:
172
+ a = self.stack[-1]
173
+ raise self._err(f"<{a.tag}> is never closed (opened on line {a.line})", a.line, f"<{a.tag}")
174
+ return self.root
175
+
176
+ def _read_text(self) -> None:
177
+ html = self.html
178
+ n = len(html)
179
+ j = self.i + 1
180
+ while j < n:
181
+ if html[j] == "<" and j + 1 < n and (html[j + 1].isalpha() or html[j + 1] in "/!"):
182
+ break
183
+ j += 1
184
+ raw = html[self.i:j]
185
+ l = self.line
186
+ self._advance_to(j)
187
+ if raw:
188
+ self._target().append(HtmlText(self._decode(raw), l))
189
+
190
+ def _read_close(self) -> None:
191
+ tag_line = self.line
192
+ end = self.html.find(">", self.i)
193
+ if end < 0:
194
+ raise self._err("unclosed '</': missing '>'")
195
+ tag = self.html[self.i + 2:end].strip().lower()
196
+ self._advance_to(end + 1)
197
+ if tag in VOID_TAGS:
198
+ return
199
+ if not self.stack:
200
+ raise self._err(f"</{tag}> has no matching opening tag", tag_line, f"</{tag}")
201
+ top = self.stack[-1]
202
+ if top.tag != tag:
203
+ raise self._err(f"expected </{top.tag}> (opened on line {top.line}) before </{tag}>",
204
+ tag_line, f"</{tag}")
205
+ self.stack.pop()
206
+ element = HtmlElement(top.tag, top.attributes, list(top.children), top.line)
207
+ if self.chunks is not None and _key_of(top.attributes) is not None:
208
+ element.chunk_text = self.html[top.start:end + 1]
209
+ self._target().append(element)
210
+
211
+ def _read_open(self) -> None:
212
+ html = self.html
213
+ n = len(html)
214
+ tag_start = self.i
215
+ tag_line = self.line
216
+ j = self.i + 1
217
+ name_start = j
218
+ while j < n and (html[j].isalnum() or html[j] == "-"):
219
+ j += 1
220
+ tag = html[name_start:j].lower()
221
+ attributes: dict[str, str] = {}
222
+ self_close = False
223
+
224
+ while True:
225
+ while j < n and html[j].isspace():
226
+ j += 1
227
+ if j >= n:
228
+ raise self._err(f"unclosed tag <{tag}>: missing '>'", tag_line, f"<{tag}")
229
+ ch = html[j]
230
+ if ch == ">":
231
+ j += 1
232
+ break
233
+ if ch == "/" and j + 1 < n and html[j + 1] == ">":
234
+ self_close = True
235
+ j += 2
236
+ break
237
+ if ch == "/":
238
+ j += 1
239
+ continue
240
+
241
+ attr_start = j
242
+ while j < n and not html[j].isspace() and html[j] not in "=>/":
243
+ j += 1
244
+ if j == attr_start:
245
+ raise self._err(f"invalid attribute in tag <{tag}>", tag_line, f"<{tag}")
246
+ name = html[attr_start:j].lower()
247
+ while j < n and html[j].isspace():
248
+ j += 1
249
+
250
+ value = ""
251
+ if j < n and html[j] == "=":
252
+ j += 1
253
+ while j < n and html[j].isspace():
254
+ j += 1
255
+ if j < n and html[j] in "\"'":
256
+ quote = html[j]
257
+ end = html.find(quote, j + 1)
258
+ if end < 0:
259
+ raise self._err(f"attribute '{name}' has an unclosed quote", tag_line, name)
260
+ value = html[j + 1:end]
261
+ j = end + 1
262
+ else:
263
+ val_start = j
264
+ while j < n and not html[j].isspace() and html[j] != ">":
265
+ j += 1
266
+ value = html[val_start:j]
267
+ if name not in attributes:
268
+ attributes[name] = self._decode(value)
269
+ self._advance_to(j)
270
+
271
+ if tag == "script":
272
+ raise self._err("<script> is not allowed", tag_line, "<script")
273
+ if tag == "style":
274
+ raise self._err('<style> is not supported yet; use style="..."', tag_line, "<style")
275
+ if tag in VOID_TAGS or self_close:
276
+ self._target().append(HtmlElement(tag, attributes, [], tag_line))
277
+ return
278
+ key = _key_of(attributes) if self.chunks is not None else None
279
+ hit = self.chunks.get(tag + "|" + key) if key is not None else None
280
+ if hit is not None and html.startswith(hit.text, tag_start):
281
+ self._advance_to(tag_start + len(hit.text))
282
+ self._target().append(HtmlCachedElement(hit, tag_line))
283
+ else:
284
+ self.stack.append(_Open(tag, attributes, tag_line, tag_start))
285
+
286
+ @staticmethod
287
+ def _decode(t: str) -> str:
288
+ if "&" not in t:
289
+ return t
290
+ out: list[str] = []
291
+ k = 0
292
+ n = len(t)
293
+ while k < n:
294
+ c = t[k]
295
+ if c == "&":
296
+ end = t.find(";", k)
297
+ if k + 2 <= end <= k + 10:
298
+ name = t[k + 1:end]
299
+ r: str | None = None
300
+ if name == "amp":
301
+ r = "&"
302
+ elif name == "lt":
303
+ r = "<"
304
+ elif name == "gt":
305
+ r = ">"
306
+ elif name == "quot":
307
+ r = '"'
308
+ elif name == "apos":
309
+ r = "'"
310
+ elif name == "nbsp":
311
+ r = "\u00A0"
312
+ elif name[:2] in ("#x", "#X"):
313
+ d = name[2:]
314
+ if _HEX_RE.match(d) and int(d, 16) <= 0x10FFFF:
315
+ r = chr(int(d, 16))
316
+ elif name.startswith("#"):
317
+ d = name[1:]
318
+ if _DEC_RE.match(d) and int(d) <= 0x10FFFF:
319
+ r = chr(int(d))
320
+ if r is not None:
321
+ out.append(r)
322
+ k = end + 1
323
+ continue
324
+ out.append(c)
325
+ k += 1
326
+ return "".join(out)
@@ -0,0 +1,82 @@
1
+ """Built-in icons (port of IconSet.kt). 24x24 grid, stroke 2, round caps."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+
5
+ PREFIX = "icon:"
6
+ GRID = 24.0
7
+ STROKE = 2.0
8
+ DOT_RADIUS = 1.6
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Line:
13
+ points: tuple[float, ...]
14
+ closed: bool = False
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Circle:
19
+ cx: float
20
+ cy: float
21
+ r: float
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Dot:
26
+ cx: float
27
+ cy: float
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Rect:
32
+ left: float
33
+ top: float
34
+ right: float
35
+ bottom: float
36
+ radius: float
37
+
38
+
39
+ _SHAPES: dict[str, list] = {
40
+ "arrow-left": [Line((19, 12, 5, 12,)), Line((12, 19, 5, 12, 12, 5,))],
41
+ "arrow-right": [Line((5, 12, 19, 12,)), Line((12, 5, 19, 12, 12, 19,))],
42
+ "arrow-up": [Line((12, 19, 12, 5,)), Line((5, 12, 12, 5, 19, 12,))],
43
+ "arrow-down": [Line((12, 5, 12, 19,)), Line((5, 12, 12, 19, 19, 12,))],
44
+ "chevron-left": [Line((15, 5, 8, 12, 15, 19,))],
45
+ "chevron-right": [Line((9, 5, 16, 12, 9, 19,))],
46
+ "chevron-up": [Line((5, 15, 12, 8, 19, 15,))],
47
+ "chevron-down": [Line((5, 9, 12, 16, 19, 9,))],
48
+ "close": [Line((6, 6, 18, 18,)), Line((18, 6, 6, 18,))],
49
+ "check": [Line((4, 12.5, 9.5, 18, 20, 6.5,))],
50
+ "plus": [Line((12, 5, 12, 19,)), Line((5, 12, 19, 12,))],
51
+ "minus": [Line((5, 12, 19, 12,))],
52
+ "menu": [Line((4, 6, 20, 6,)), Line((4, 12, 20, 12,)), Line((4, 18, 20, 18,))],
53
+ "search": [Circle(10.5, 10.5, 6.5), Line((15.3, 15.3, 20, 20,))],
54
+ "more-vertical": [Dot(12, 5), Dot(12, 12), Dot(12, 19)],
55
+ "more-horizontal": [Dot(5, 12), Dot(12, 12), Dot(19, 12)],
56
+ "home": [Line((3, 11, 12, 3, 21, 11,)), Line((5, 9.5, 5, 21, 19, 21, 19, 9.5,)), Line((10, 21, 10, 15, 14, 15, 14, 21,))],
57
+ "user": [Circle(12, 8, 4), Line((5, 21, 5, 19.5, 5.8, 17, 7.6, 15.3, 10, 14.3, 12, 14, 14, 14.3, 16.4, 15.3, 18.2, 17, 19, 19.5, 19, 21,))],
58
+ "trash": [Line((4, 7, 20, 7,)), Line((9, 7, 9, 4, 15, 4, 15, 7,)), Line((6, 7, 7, 21, 17, 21, 18, 7,)), Line((10, 11, 10, 17,)), Line((14, 11, 14, 17,))],
59
+ "edit": [Line((4, 20, 5, 15, 16, 4, 20, 8, 9, 19,), True), Line((14, 6, 18, 10,))],
60
+ "star": [Line((12, 3, 14.4, 9.3, 21.1, 9.6, 15.9, 13.9, 17.6, 20.4, 12, 16.7, 6.4, 20.4, 8.1, 13.9, 2.9, 9.6, 9.6, 9.3,), True)],
61
+ "lock": [Rect(5, 11, 19, 21, 2), Line((8, 11, 8, 8.2, 8.6, 6.4, 10, 5.1, 12, 4.6, 14, 5.1, 15.4, 6.4, 16, 8.2, 16, 11,))],
62
+ "eye": [Line((2, 12, 4, 9.6, 7, 7.6, 9.5, 6.6, 12, 6.3, 14.5, 6.6, 17, 7.6, 20, 9.6, 22, 12, 20, 14.4, 17, 16.4, 14.5, 17.4, 12, 17.7, 9.5, 17.4, 7, 16.4, 4, 14.4,), True), Circle(12, 12, 3)],
63
+ "mail": [Rect(3, 5, 21, 19, 2), Line((3, 7, 12, 13, 21, 7,))],
64
+ "info": [Circle(12, 12, 9), Line((12, 11, 12, 17,)), Dot(12, 7.5)],
65
+ "alert": [Line((12, 3, 22, 20, 2, 20,), True), Line((12, 9, 12, 14,)), Dot(12, 17)],
66
+ "calendar": [Rect(3, 5, 21, 21, 2), Line((3, 10, 21, 10,)), Line((8, 3, 8, 7,)), Line((16, 3, 16, 7,))],
67
+ "logout": [Line((9, 4, 5, 4, 5, 20, 9, 20,)), Line((10, 12, 20, 12,)), Line((16, 8, 20, 12, 16, 16,))],
68
+ "bookmark": [Line((6, 3, 18, 3, 18, 21, 12, 16, 6, 21,), True)],
69
+ "clock": [Circle(12, 12, 9), Line((12, 7, 12, 12, 16, 14,))],
70
+ }
71
+
72
+
73
+ def name_of(source: str) -> str | None:
74
+ return source[len(PREFIX):].strip() if source.startswith(PREFIX) else None
75
+
76
+
77
+ def shapes_of(name: str):
78
+ return _SHAPES.get(name)
79
+
80
+
81
+ def names() -> list[str]:
82
+ return list(_SHAPES.keys())