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.
- nativelayout/core/__init__.py +14 -0
- nativelayout/core/css.py +221 -0
- nativelayout/core/differ.py +140 -0
- nativelayout/core/errors.py +25 -0
- nativelayout/core/expander.py +1266 -0
- nativelayout/core/html_parser.py +326 -0
- nativelayout/core/icons.py +82 -0
- nativelayout/core/layout_engine.py +1004 -0
- nativelayout/core/layout_host.py +248 -0
- nativelayout/core/program.py +290 -0
- nativelayout/core/scopes.py +52 -0
- nativelayout/core/tree.py +406 -0
- nativelayout/host/__init__.py +4 -0
- nativelayout/host/draw.py +189 -0
- nativelayout/host/flex.py +365 -0
- nativelayout/host/kivy_host.py +619 -0
- nativelayout/host/native_view.py +86 -0
- nativelayout/host/widgets.py +334 -0
- nativelayout-1.2.0.dist-info/METADATA +93 -0
- nativelayout-1.2.0.dist-info/RECORD +23 -0
- nativelayout-1.2.0.dist-info/WHEEL +5 -0
- nativelayout-1.2.0.dist-info/licenses/LICENSE +373 -0
- nativelayout-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
"""Converts HTML into a native tree (port of LayoutEngine.kt)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
|
|
8
|
+
from . import css as C
|
|
9
|
+
from . import icons as IconSet
|
|
10
|
+
from .css import CssDeclaration, _to_float
|
|
11
|
+
from .html_parser import HtmlElement, HtmlNode, HtmlParser, HtmlText, MarkupError
|
|
12
|
+
from .tree import (
|
|
13
|
+
Align, AnimKind, Animation, Colors, Corners, Direction, Dock, FieldType, FontFamily,
|
|
14
|
+
Gradient, ImageFit, ImagePos, Justify, Length, LineHeightFixed, LineHeightMultiple, Native, NativeBox,
|
|
15
|
+
NativeButton, NativeCheckbox, NativeField, NativeImage, NativeItem, NativeList,
|
|
16
|
+
NativeStyle, NativeText, Position, Sides, Span, StateStyle, StateStyles, TextAlign,
|
|
17
|
+
TextTransform, native_children,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LayoutWarning:
|
|
23
|
+
message: str
|
|
24
|
+
line: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LayoutResult:
|
|
29
|
+
root: Native
|
|
30
|
+
warnings: list[LayoutWarning]
|
|
31
|
+
by_id: dict[str, Native]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LayoutEngine:
|
|
35
|
+
@staticmethod
|
|
36
|
+
def build(html: str, start_line: int = 1, variables: dict[str, str] | None = None) -> LayoutResult:
|
|
37
|
+
return _LayoutBuilder(html, start_line, variables or {}).build()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_SUPPORTED_TAGS = frozenset({
|
|
41
|
+
"div", "span", "p", "h1", "h2", "h3", "button", "input", "textarea", "img", "ul", "li",
|
|
42
|
+
"fieldset", "legend", "dialog", "header", "footer",
|
|
43
|
+
})
|
|
44
|
+
_TAGS_SUGGESTION = ("supported tags: div, span, p, h1, h2, h3, button, input, textarea, img, "
|
|
45
|
+
"ul, li, fieldset, legend, dialog, header, footer")
|
|
46
|
+
_FIELDSET_BORDER = 0xFFBDBDBD
|
|
47
|
+
_DIALOG_BACKDROP = 0x80000000
|
|
48
|
+
_BORDER_STYLES = frozenset({"solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset"})
|
|
49
|
+
_DECORATION_STYLES = frozenset({"solid", "double", "dotted", "dashed", "wavy"})
|
|
50
|
+
_REGEX_VAR = re.compile(r"var\(\s*(--[\w-]+)\s*\)", re.ASCII)
|
|
51
|
+
_REGEX_DEG = re.compile(r"^(-?\d*\.?\d+)deg$", re.ASCII)
|
|
52
|
+
_REGEX_INT = re.compile(r"^[+-]?\d+$", re.ASCII)
|
|
53
|
+
_WS = re.compile(r"\s+")
|
|
54
|
+
|
|
55
|
+
_DIRECTION_ANGLES = {
|
|
56
|
+
frozenset({"top"}): 0, frozenset({"top", "right"}): 45, frozenset({"right"}): 90,
|
|
57
|
+
frozenset({"bottom", "right"}): 135, frozenset({"bottom"}): 180,
|
|
58
|
+
frozenset({"bottom", "left"}): 225, frozenset({"left"}): 270, frozenset({"top", "left"}): 315,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _round(x: float) -> int:
|
|
63
|
+
return int(math.floor(x + 0.5))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _int_or_none(s: str) -> int | None:
|
|
67
|
+
return int(s) if _REGEX_INT.match(s) else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _blank(s: str) -> bool:
|
|
71
|
+
return s.strip() == ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _vertical(rem: float) -> Sides:
|
|
75
|
+
return Sides(top=Length.rem(rem), bottom=Length.rem(rem))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _all_sides(rem: float) -> Sides:
|
|
79
|
+
l = Length.rem(rem)
|
|
80
|
+
return Sides(l, l, l, l)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _inherit(p: NativeStyle) -> NativeStyle:
|
|
84
|
+
return NativeStyle(
|
|
85
|
+
text_color=p.text_color, font_size=p.font_size, bold=p.bold, text_align=p.text_align,
|
|
86
|
+
italic=p.italic, underline=p.underline, strike=p.strike,
|
|
87
|
+
letter_spacing=p.letter_spacing, line_height=p.line_height,
|
|
88
|
+
text_transform=p.text_transform, font_family=p.font_family)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _image_name_of(v: str) -> str | None:
|
|
92
|
+
"""`url(name)`, with or without quotes: the name of an image. None when the text is not that."""
|
|
93
|
+
t = v.strip()
|
|
94
|
+
if not t.lower().startswith("url(") or not t.endswith(")"):
|
|
95
|
+
return None
|
|
96
|
+
name = t[4:-1].strip().strip("\"'").strip()
|
|
97
|
+
return name or None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _key_of(el: HtmlElement) -> str | None:
|
|
101
|
+
v = el.attributes.get("key")
|
|
102
|
+
if v is None:
|
|
103
|
+
return None
|
|
104
|
+
v = v.strip()
|
|
105
|
+
return v or None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scope_of(el: HtmlElement) -> str | None:
|
|
109
|
+
v = el.attributes.get("scope")
|
|
110
|
+
if v is None:
|
|
111
|
+
return None
|
|
112
|
+
v = v.strip()
|
|
113
|
+
return v or None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _transformed(text: str, t: TextTransform) -> str:
|
|
117
|
+
if t == TextTransform.NONE:
|
|
118
|
+
return text
|
|
119
|
+
if t == TextTransform.UPPERCASE:
|
|
120
|
+
return text.upper()
|
|
121
|
+
if t == TextTransform.LOWERCASE:
|
|
122
|
+
return text.lower()
|
|
123
|
+
out = []
|
|
124
|
+
start = True
|
|
125
|
+
for ch in text:
|
|
126
|
+
if start and ch.isalpha():
|
|
127
|
+
u = ch.upper()
|
|
128
|
+
out.append(u if len(u) == 1 else ch)
|
|
129
|
+
else:
|
|
130
|
+
out.append(ch)
|
|
131
|
+
start = ch in " \t\n\r"
|
|
132
|
+
return "".join(out)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _split_ws(s: str) -> list[str]:
|
|
136
|
+
return [p for p in C.split_top_level(s, " ") if not _blank(p)]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _LayoutBuilder:
|
|
140
|
+
def __init__(self, html: str, start_line: int, variables: dict[str, str]):
|
|
141
|
+
self.html = html
|
|
142
|
+
self.start_line = start_line
|
|
143
|
+
self.variables = variables
|
|
144
|
+
self.source = "\n" * max(start_line - 1, 0) + html
|
|
145
|
+
self.warnings: list[LayoutWarning] = []
|
|
146
|
+
self.depth = 0
|
|
147
|
+
self.parent_tag: str | None = None
|
|
148
|
+
self.group_disabled = False
|
|
149
|
+
self.warned: set[tuple[str, int]] = set()
|
|
150
|
+
self.ids: dict[str, Native] = {}
|
|
151
|
+
self.reserved_ids: set[str] = set()
|
|
152
|
+
|
|
153
|
+
# -- errors and warnings --------------------------------------------------
|
|
154
|
+
def _err(self, message: str, line: int, word: str | None = None, suggestion: str | None = None):
|
|
155
|
+
return MarkupError(message, line, self.source, word, suggestion)
|
|
156
|
+
|
|
157
|
+
def _warn(self, message: str, line: int) -> None:
|
|
158
|
+
k = (message, line)
|
|
159
|
+
if k not in self.warned:
|
|
160
|
+
self.warned.add(k)
|
|
161
|
+
self.warnings.append(LayoutWarning(message, line))
|
|
162
|
+
|
|
163
|
+
# -- input ---------------------------------------------------------
|
|
164
|
+
def build(self) -> LayoutResult:
|
|
165
|
+
nodes = HtmlParser(self.html, self.start_line, self.source).parse()
|
|
166
|
+
relevant = [n for n in nodes if not (isinstance(n, HtmlText) and _blank(n.text))]
|
|
167
|
+
if len(relevant) == 1 and isinstance(relevant[0], HtmlElement):
|
|
168
|
+
root_el = relevant[0]
|
|
169
|
+
else:
|
|
170
|
+
if len(relevant) > 1:
|
|
171
|
+
line = relevant[1].line
|
|
172
|
+
elif relevant:
|
|
173
|
+
line = relevant[0].line
|
|
174
|
+
else:
|
|
175
|
+
line = self.start_line
|
|
176
|
+
raise self._err("must have a single root tag", line)
|
|
177
|
+
root = self._convert_element(root_el, NativeStyle())
|
|
178
|
+
self._assign_keys(root, "")
|
|
179
|
+
return LayoutResult(root, list(self.warnings), dict(self.ids))
|
|
180
|
+
|
|
181
|
+
def _assign_keys(self, n: Native, path: str) -> None:
|
|
182
|
+
n.key = f"#{n.id}" if n.id is not None else (path if path else "/")
|
|
183
|
+
seen: set[str] = set()
|
|
184
|
+
for i, f in enumerate(native_children(n)):
|
|
185
|
+
hint = f.key_hint
|
|
186
|
+
if hint is not None:
|
|
187
|
+
if hint in seen:
|
|
188
|
+
raise self._err(f"duplicate key '{hint}'", f.line, hint)
|
|
189
|
+
seen.add(hint)
|
|
190
|
+
self._assign_keys(f, f"{path}/{('~' + hint) if hint is not None else i}")
|
|
191
|
+
|
|
192
|
+
# -- variables and declarations ----------------------------------------
|
|
193
|
+
def _substitute_variables(self, text: str, line: int) -> str:
|
|
194
|
+
if "var(" not in text:
|
|
195
|
+
return text
|
|
196
|
+
|
|
197
|
+
def rep(m):
|
|
198
|
+
name = m.group(1)
|
|
199
|
+
if name not in self.variables:
|
|
200
|
+
raise self._err(f"the variable '{name}' is not defined", line, name)
|
|
201
|
+
return self.variables[name]
|
|
202
|
+
return _REGEX_VAR.sub(rep, text)
|
|
203
|
+
|
|
204
|
+
def _declarations_of(self, el: HtmlElement) -> list[CssDeclaration]:
|
|
205
|
+
s = el.attributes.get("style")
|
|
206
|
+
if s is None:
|
|
207
|
+
return []
|
|
208
|
+
return C.declarations(self._substitute_variables(s, el.line), el.line, self._warn)
|
|
209
|
+
|
|
210
|
+
# -- style ----------------------------------------------------------
|
|
211
|
+
def _tag_defaults(self, el: HtmlElement, b: NativeStyle) -> NativeStyle:
|
|
212
|
+
t = el.tag
|
|
213
|
+
if t == "h1":
|
|
214
|
+
return replace(b, font_size=Length.rem(2.0), bold=True, margin=_vertical(1.3))
|
|
215
|
+
if t == "h2":
|
|
216
|
+
return replace(b, font_size=Length.rem(1.5), bold=True, margin=_vertical(1.2))
|
|
217
|
+
if t == "h3":
|
|
218
|
+
return replace(b, font_size=Length.rem(1.17), bold=True, margin=_vertical(1.1))
|
|
219
|
+
if t == "p":
|
|
220
|
+
return replace(b, margin=_vertical(1.0))
|
|
221
|
+
if t == "fieldset":
|
|
222
|
+
return replace(b, padding=_all_sides(0.75), margin=_vertical(0.5),
|
|
223
|
+
border_width=Length.px(1.0), border_color=_FIELDSET_BORDER,
|
|
224
|
+
border_radius=Length.px(6.0))
|
|
225
|
+
if t == "legend":
|
|
226
|
+
return replace(b, bold=True, margin=Sides(bottom=Length.rem(0.5)))
|
|
227
|
+
if t == "dialog":
|
|
228
|
+
return replace(b, background_color=_DIALOG_BACKDROP, justify=Justify.CENTER,
|
|
229
|
+
align_items=Align.CENTER)
|
|
230
|
+
if t == "footer":
|
|
231
|
+
return replace(b, direction=Direction.ROW, justify=Justify.CENTER,
|
|
232
|
+
align_items=Align.CENTER)
|
|
233
|
+
if t == "ul":
|
|
234
|
+
return replace(b, margin=_vertical(1.0), padding=Sides(left=Length.rem(1.5)))
|
|
235
|
+
if t == "button":
|
|
236
|
+
return replace(b, padding=Sides(Length.rem(0.5), Length.rem(1.0), Length.rem(0.5), Length.rem(1.0)),
|
|
237
|
+
background_color=Colors.BUTTON_GRAY, border_radius=Length.px(4.0),
|
|
238
|
+
text_align=TextAlign.CENTER, text_color=Colors.BLACK, bold=False)
|
|
239
|
+
if t in ("input", "textarea"):
|
|
240
|
+
if (el.attributes.get("type") or "text").strip().lower() == "checkbox":
|
|
241
|
+
return b
|
|
242
|
+
return replace(b, padding=_all_sides(0.5), border_width=Length.px(1.0),
|
|
243
|
+
border_color=Colors.BORDER_GRAY, border_radius=Length.px(2.0),
|
|
244
|
+
background_color=Colors.WHITE, text_color=Colors.BLACK)
|
|
245
|
+
return b
|
|
246
|
+
|
|
247
|
+
def _compute_style(self, el: HtmlElement, parent_style: NativeStyle) -> NativeStyle:
|
|
248
|
+
e = self._tag_defaults(el, _inherit(parent_style))
|
|
249
|
+
for d in self._declarations_of(el):
|
|
250
|
+
e = self._apply_declaration(e, d)
|
|
251
|
+
pressed = self._state_of(el, "style-pressed", False)
|
|
252
|
+
disabled = self._state_of(el, "style-disabled", True)
|
|
253
|
+
focused = self._state_of(el, "style-focus", False)
|
|
254
|
+
if pressed is not None or disabled is not None or focused is not None:
|
|
255
|
+
e = replace(e, states=StateStyles(pressed, disabled, focused))
|
|
256
|
+
return e
|
|
257
|
+
|
|
258
|
+
def _fix_position(self, el: HtmlElement, s: NativeStyle, level: int) -> NativeStyle:
|
|
259
|
+
"""`position: absolute` only makes sense inside a box of the flow: elsewhere it is ignored with a warning."""
|
|
260
|
+
if s.position != Position.ABSOLUTE:
|
|
261
|
+
return s
|
|
262
|
+
if el.tag in ("header", "footer", "dialog"):
|
|
263
|
+
why = f"<{el.tag}> has its own place"
|
|
264
|
+
elif el.tag == "li":
|
|
265
|
+
why = "<li> stays in its list"
|
|
266
|
+
elif level == 0:
|
|
267
|
+
why = "the root of the screen has no parent"
|
|
268
|
+
else:
|
|
269
|
+
return s
|
|
270
|
+
self._warn(f"CSS: 'position: absolute' is ignored: {why}", el.line)
|
|
271
|
+
return replace(s, position=Position.STATIC)
|
|
272
|
+
|
|
273
|
+
def _state_of(self, el: HtmlElement, attribute: str, allow_opacity: bool) -> StateStyle | None:
|
|
274
|
+
text = el.attributes.get(attribute)
|
|
275
|
+
if text is None:
|
|
276
|
+
return None
|
|
277
|
+
background = color = border = opacity = None
|
|
278
|
+
for d in C.declarations(text, el.line, self._warn):
|
|
279
|
+
v = d.value.strip()
|
|
280
|
+
|
|
281
|
+
def bad(d=d):
|
|
282
|
+
self._warn(f"CSS: invalid value in '{d.name}: {d.value}', ignored", d.line)
|
|
283
|
+
|
|
284
|
+
if d.name in ("background", "background-color"):
|
|
285
|
+
c = C.color(v)
|
|
286
|
+
if c is None:
|
|
287
|
+
bad()
|
|
288
|
+
else:
|
|
289
|
+
background = c
|
|
290
|
+
elif d.name == "color":
|
|
291
|
+
c = C.color(v)
|
|
292
|
+
if c is None:
|
|
293
|
+
bad()
|
|
294
|
+
else:
|
|
295
|
+
color = c
|
|
296
|
+
elif d.name == "border-color":
|
|
297
|
+
c = C.color(v)
|
|
298
|
+
if c is None:
|
|
299
|
+
bad()
|
|
300
|
+
else:
|
|
301
|
+
border = c
|
|
302
|
+
elif d.name == "opacity":
|
|
303
|
+
if not allow_opacity:
|
|
304
|
+
self._warn("CSS: 'opacity' is only supported in :disabled", d.line)
|
|
305
|
+
else:
|
|
306
|
+
f = C.fraction(v)
|
|
307
|
+
if f is None:
|
|
308
|
+
bad()
|
|
309
|
+
else:
|
|
310
|
+
opacity = f
|
|
311
|
+
else:
|
|
312
|
+
self._warn(f"CSS: '{d.name}' is not supported in :pressed, :disabled or :focus "
|
|
313
|
+
"(use background, color, border-color; opacity only in :disabled)", d.line)
|
|
314
|
+
if background is None and color is None and border is None and opacity is None:
|
|
315
|
+
return None
|
|
316
|
+
return StateStyle(background, color, border, opacity)
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _sides_of(v: str) -> Sides | None:
|
|
320
|
+
p = []
|
|
321
|
+
for t in _split_ws(v):
|
|
322
|
+
l = C.length(t)
|
|
323
|
+
if l is None:
|
|
324
|
+
return None
|
|
325
|
+
p.append(l)
|
|
326
|
+
n = len(p)
|
|
327
|
+
if n == 1:
|
|
328
|
+
return Sides(p[0], p[0], p[0], p[0])
|
|
329
|
+
if n == 2:
|
|
330
|
+
return Sides(p[0], p[1], p[0], p[1])
|
|
331
|
+
if n == 3:
|
|
332
|
+
return Sides(p[0], p[1], p[2], p[1])
|
|
333
|
+
if n == 4:
|
|
334
|
+
return Sides(p[0], p[1], p[2], p[3])
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _corners_of(v: str) -> Corners | None:
|
|
339
|
+
if "/" in v:
|
|
340
|
+
return None
|
|
341
|
+
p = []
|
|
342
|
+
for t in _split_ws(v):
|
|
343
|
+
l = C.length(t)
|
|
344
|
+
if l is None or l.value < 0.0:
|
|
345
|
+
return None
|
|
346
|
+
p.append(l)
|
|
347
|
+
n = len(p)
|
|
348
|
+
if n == 1:
|
|
349
|
+
return Corners(p[0], p[0], p[0], p[0])
|
|
350
|
+
if n == 2:
|
|
351
|
+
return Corners(p[0], p[1], p[0], p[1])
|
|
352
|
+
if n == 3:
|
|
353
|
+
return Corners(p[0], p[1], p[2], p[1])
|
|
354
|
+
if n == 4:
|
|
355
|
+
return Corners(p[0], p[1], p[2], p[3])
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _with_corners(e: NativeStyle, c: Corners) -> NativeStyle:
|
|
360
|
+
if c.top_left == c.top_right == c.bottom_right == c.bottom_left:
|
|
361
|
+
return replace(e, border_radius=c.top_left, corners=None)
|
|
362
|
+
return replace(e, border_radius=Length.ZERO, corners=c)
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _direction_angle(words: str) -> int | None:
|
|
366
|
+
return _DIRECTION_ANGLES.get(frozenset(w for w in words.split(" ") if w))
|
|
367
|
+
|
|
368
|
+
def _gradient_of(self, v: str) -> Gradient | None:
|
|
369
|
+
op = v.find("(")
|
|
370
|
+
cl = v.rfind(")")
|
|
371
|
+
if op < 0 or cl <= op:
|
|
372
|
+
return None
|
|
373
|
+
args = [a.strip() for a in C.split_top_level(v[op + 1:cl], ",") if a.strip()]
|
|
374
|
+
if len(args) < 2:
|
|
375
|
+
return None
|
|
376
|
+
angle = 180
|
|
377
|
+
start = 0
|
|
378
|
+
first = args[0].lower()
|
|
379
|
+
deg = _REGEX_DEG.match(first)
|
|
380
|
+
if deg is not None:
|
|
381
|
+
angle = (_round(float(deg.group(1))) % 360 + 360) % 360
|
|
382
|
+
start = 1
|
|
383
|
+
elif first.startswith("to "):
|
|
384
|
+
a = self._direction_angle(first[3:].strip())
|
|
385
|
+
if a is None:
|
|
386
|
+
return None
|
|
387
|
+
angle = a
|
|
388
|
+
start = 1
|
|
389
|
+
colors: list[int] = []
|
|
390
|
+
for i in range(start, len(args)):
|
|
391
|
+
found = None
|
|
392
|
+
for tok in _split_ws(args[i]):
|
|
393
|
+
c = C.color(tok)
|
|
394
|
+
if c is not None:
|
|
395
|
+
found = c
|
|
396
|
+
break
|
|
397
|
+
if found is None:
|
|
398
|
+
return None
|
|
399
|
+
colors.append(found)
|
|
400
|
+
return Gradient(angle, tuple(colors)) if len(colors) >= 2 else None
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _elevation_of(v: str) -> Length | None:
|
|
404
|
+
first = C.split_top_level(v, ",")[0].strip()
|
|
405
|
+
if first.lower() == "none":
|
|
406
|
+
return Length.ZERO
|
|
407
|
+
lengths: list[Length] = []
|
|
408
|
+
for tok in _split_ws(first):
|
|
409
|
+
if tok.lower() == "inset":
|
|
410
|
+
return None
|
|
411
|
+
ln = C.length(tok)
|
|
412
|
+
if ln is not None:
|
|
413
|
+
lengths.append(ln)
|
|
414
|
+
elif C.color(tok) is None:
|
|
415
|
+
return None
|
|
416
|
+
if not 2 <= len(lengths) <= 4:
|
|
417
|
+
return None
|
|
418
|
+
ref = lengths[2] if len(lengths) >= 3 and lengths[2].value > 0.0 else lengths[1]
|
|
419
|
+
return Length(max(ref.value, 0.0) / 2.0, ref.scalable)
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _font_family_of(v: str) -> FontFamily | None:
|
|
423
|
+
for raw in C.split_top_level(v, ","):
|
|
424
|
+
name = raw.strip().strip("\"'").lower()
|
|
425
|
+
if name in ("sans-serif", "system-ui", "roboto", "arial", "helvetica", "default"):
|
|
426
|
+
return FontFamily.SANS
|
|
427
|
+
if name in ("serif", "georgia", "times", "times new roman"):
|
|
428
|
+
return FontFamily.SERIF
|
|
429
|
+
if name in ("monospace", "courier", "courier new", "consolas", "menlo"):
|
|
430
|
+
return FontFamily.MONOSPACE
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _animation_of(v: str) -> Animation | None:
|
|
435
|
+
parts = [p for p in _WS.split(v.lower()) if p]
|
|
436
|
+
if not parts or len(parts) > 2:
|
|
437
|
+
return None
|
|
438
|
+
kinds = {"none": AnimKind.NONE, "fade": AnimKind.FADE,
|
|
439
|
+
"slide-up": AnimKind.SLIDE_UP, "slide-left": AnimKind.SLIDE_LEFT}
|
|
440
|
+
kind = kinds.get(parts[0])
|
|
441
|
+
if kind is None:
|
|
442
|
+
return None
|
|
443
|
+
ms = 200
|
|
444
|
+
if len(parts) == 2:
|
|
445
|
+
t = parts[1]
|
|
446
|
+
if t.endswith("ms"):
|
|
447
|
+
f = _to_float(t[:-2])
|
|
448
|
+
r = None if f is None else int(f)
|
|
449
|
+
elif t.endswith("s"):
|
|
450
|
+
f = _to_float(t[:-1])
|
|
451
|
+
r = None if f is None else int(f * 1000.0)
|
|
452
|
+
else:
|
|
453
|
+
r = None
|
|
454
|
+
if r is None:
|
|
455
|
+
return None
|
|
456
|
+
ms = r
|
|
457
|
+
if ms < 0 or ms > 2000:
|
|
458
|
+
return None
|
|
459
|
+
return Animation(kind, ms)
|
|
460
|
+
|
|
461
|
+
def _apply_declaration(self, e: NativeStyle, d: CssDeclaration) -> NativeStyle:
|
|
462
|
+
v = d.value.strip()
|
|
463
|
+
vl = v.lower()
|
|
464
|
+
name = d.name
|
|
465
|
+
|
|
466
|
+
def invalid() -> NativeStyle:
|
|
467
|
+
self._warn(f"CSS: invalid value in '{d.name}: {d.value}', ignored", d.line)
|
|
468
|
+
return e
|
|
469
|
+
|
|
470
|
+
def unsupported(msg: str) -> NativeStyle:
|
|
471
|
+
self._warn(msg, d.line)
|
|
472
|
+
return e
|
|
473
|
+
|
|
474
|
+
def ln() -> Length | None:
|
|
475
|
+
return C.length(v)
|
|
476
|
+
|
|
477
|
+
if name == "color":
|
|
478
|
+
c = C.color(v)
|
|
479
|
+
return invalid() if c is None else replace(e, text_color=c)
|
|
480
|
+
if name in ("background", "background-color", "background-image"):
|
|
481
|
+
if vl.startswith("linear-gradient(") and vl.endswith(")"):
|
|
482
|
+
g = self._gradient_of(v)
|
|
483
|
+
return invalid() if g is None else replace(e, gradient=g, background_image=None)
|
|
484
|
+
image = _image_name_of(v)
|
|
485
|
+
if image is not None:
|
|
486
|
+
return replace(e, background_image=image)
|
|
487
|
+
if name == "background-image":
|
|
488
|
+
return replace(e, gradient=None, background_image=None) if vl == "none" else invalid()
|
|
489
|
+
c = C.color(v)
|
|
490
|
+
if c is None:
|
|
491
|
+
return invalid()
|
|
492
|
+
return replace(e, background_color=c, gradient=None,
|
|
493
|
+
background_image=None if name == "background" else e.background_image)
|
|
494
|
+
if name == "font-size":
|
|
495
|
+
l = ln()
|
|
496
|
+
return replace(e, font_size=l) if l is not None and l.value > 0.0 else invalid()
|
|
497
|
+
if name == "font-weight":
|
|
498
|
+
n = _int_or_none(vl)
|
|
499
|
+
if vl in ("bold", "bolder"):
|
|
500
|
+
return replace(e, bold=True)
|
|
501
|
+
if vl in ("normal", "lighter"):
|
|
502
|
+
return replace(e, bold=False)
|
|
503
|
+
if n is not None:
|
|
504
|
+
return replace(e, bold=n >= 600)
|
|
505
|
+
return invalid()
|
|
506
|
+
if name == "padding":
|
|
507
|
+
s = self._sides_of(v)
|
|
508
|
+
return invalid() if s is None else replace(e, padding=s)
|
|
509
|
+
if name == "margin":
|
|
510
|
+
s = self._sides_of(v)
|
|
511
|
+
return invalid() if s is None else replace(e, margin=s)
|
|
512
|
+
if name in ("padding-top", "padding-right", "padding-bottom", "padding-left"):
|
|
513
|
+
l = ln()
|
|
514
|
+
return invalid() if l is None else replace(
|
|
515
|
+
e, padding=replace(e.padding, **{name.split("-")[1]: l}))
|
|
516
|
+
if name in ("margin-top", "margin-right", "margin-bottom", "margin-left"):
|
|
517
|
+
l = ln()
|
|
518
|
+
return invalid() if l is None else replace(
|
|
519
|
+
e, margin=replace(e.margin, **{name.split("-")[1]: l}))
|
|
520
|
+
if name == "border-width":
|
|
521
|
+
l = ln()
|
|
522
|
+
return replace(e, border_width=l) if l is not None and l.value >= 0.0 else invalid()
|
|
523
|
+
if name == "border-color":
|
|
524
|
+
c = C.color(v)
|
|
525
|
+
return invalid() if c is None else replace(e, border_color=c)
|
|
526
|
+
if name == "border-style":
|
|
527
|
+
if vl in ("none", "hidden"):
|
|
528
|
+
return replace(e, border_width=Length.ZERO)
|
|
529
|
+
return e if vl in _BORDER_STYLES else invalid()
|
|
530
|
+
if name == "text-align":
|
|
531
|
+
if vl in ("left", "start"):
|
|
532
|
+
return replace(e, text_align=TextAlign.START)
|
|
533
|
+
if vl == "center":
|
|
534
|
+
return replace(e, text_align=TextAlign.CENTER)
|
|
535
|
+
if vl in ("right", "end"):
|
|
536
|
+
return replace(e, text_align=TextAlign.END)
|
|
537
|
+
if vl == "justify":
|
|
538
|
+
self._warn("CSS: 'text-align: justify' is not supported, using start", d.line)
|
|
539
|
+
return replace(e, text_align=TextAlign.START)
|
|
540
|
+
return invalid()
|
|
541
|
+
if name == "opacity":
|
|
542
|
+
f = C.fraction(v)
|
|
543
|
+
return invalid() if f is None else replace(e, opacity=f)
|
|
544
|
+
if name == "font-style":
|
|
545
|
+
if vl in ("italic", "oblique"):
|
|
546
|
+
return replace(e, italic=True)
|
|
547
|
+
if vl == "normal":
|
|
548
|
+
return replace(e, italic=False)
|
|
549
|
+
return invalid()
|
|
550
|
+
if name in ("text-decoration", "text-decoration-line"):
|
|
551
|
+
underline = strike = none = False
|
|
552
|
+
ok = True
|
|
553
|
+
for tok in _split_ws(vl):
|
|
554
|
+
if tok == "none":
|
|
555
|
+
none = True
|
|
556
|
+
elif tok == "underline":
|
|
557
|
+
underline = True
|
|
558
|
+
elif tok == "line-through":
|
|
559
|
+
strike = True
|
|
560
|
+
elif tok == "overline":
|
|
561
|
+
self._warn("CSS: 'text-decoration: overline' is not supported, ignored", d.line)
|
|
562
|
+
elif tok in _DECORATION_STYLES or C.color(tok) is not None or C.length(tok) is not None:
|
|
563
|
+
pass
|
|
564
|
+
else:
|
|
565
|
+
ok = False
|
|
566
|
+
if not ok:
|
|
567
|
+
return invalid()
|
|
568
|
+
if none and not underline and not strike:
|
|
569
|
+
return replace(e, underline=False, strike=False)
|
|
570
|
+
return replace(e, underline=underline, strike=strike)
|
|
571
|
+
if name == "letter-spacing":
|
|
572
|
+
if vl == "normal":
|
|
573
|
+
return replace(e, letter_spacing=Length.ZERO)
|
|
574
|
+
l = ln()
|
|
575
|
+
return invalid() if l is None else replace(e, letter_spacing=l)
|
|
576
|
+
if name == "line-height":
|
|
577
|
+
number = _to_float(vl)
|
|
578
|
+
percent = _to_float(vl[:-1]) if vl.endswith("%") else None
|
|
579
|
+
l = ln()
|
|
580
|
+
if vl == "normal":
|
|
581
|
+
return replace(e, line_height=None)
|
|
582
|
+
if number is not None and number > 0.0:
|
|
583
|
+
return replace(e, line_height=LineHeightMultiple(number))
|
|
584
|
+
if percent is not None and percent > 0.0:
|
|
585
|
+
return replace(e, line_height=LineHeightMultiple(percent / 100.0))
|
|
586
|
+
if l is not None and l.value > 0.0:
|
|
587
|
+
return replace(e, line_height=LineHeightFixed(l))
|
|
588
|
+
return invalid()
|
|
589
|
+
if name == "text-transform":
|
|
590
|
+
m = {"none": TextTransform.NONE, "uppercase": TextTransform.UPPERCASE,
|
|
591
|
+
"lowercase": TextTransform.LOWERCASE, "capitalize": TextTransform.CAPITALIZE}
|
|
592
|
+
return replace(e, text_transform=m[vl]) if vl in m else invalid()
|
|
593
|
+
if name == "font-family":
|
|
594
|
+
fam = self._font_family_of(v)
|
|
595
|
+
if fam is not None:
|
|
596
|
+
return replace(e, font_family=fam)
|
|
597
|
+
return unsupported(f"CSS: no known family in 'font-family: {v}' (use sans-serif, serif or monospace), ignored")
|
|
598
|
+
if name in ("overflow", "overflow-x", "overflow-y"):
|
|
599
|
+
if vl in ("hidden", "clip"):
|
|
600
|
+
return replace(e, overflow_hidden=True)
|
|
601
|
+
if vl == "visible":
|
|
602
|
+
return replace(e, overflow_hidden=False)
|
|
603
|
+
return unsupported(f"CSS: 'overflow: {vl}' is not supported (use hidden or visible)")
|
|
604
|
+
if name == "box-shadow":
|
|
605
|
+
el_ = self._elevation_of(v)
|
|
606
|
+
return invalid() if el_ is None else replace(e, elevation=el_)
|
|
607
|
+
if name in ("min-width", "min-height"):
|
|
608
|
+
field = name.replace("-", "_")
|
|
609
|
+
if vl in ("0", "auto"):
|
|
610
|
+
return replace(e, **{field: None})
|
|
611
|
+
l = ln()
|
|
612
|
+
return invalid() if l is None else replace(e, **{field: l})
|
|
613
|
+
if name in ("max-width", "max-height"):
|
|
614
|
+
field = name.replace("-", "_")
|
|
615
|
+
if vl == "none":
|
|
616
|
+
return replace(e, **{field: None})
|
|
617
|
+
l = ln()
|
|
618
|
+
return invalid() if l is None else replace(e, **{field: l})
|
|
619
|
+
if name == "align-self":
|
|
620
|
+
if vl == "auto":
|
|
621
|
+
return replace(e, align_self=None)
|
|
622
|
+
if vl in ("flex-start", "start"):
|
|
623
|
+
return replace(e, align_self=Align.START)
|
|
624
|
+
if vl == "center":
|
|
625
|
+
return replace(e, align_self=Align.CENTER)
|
|
626
|
+
if vl in ("flex-end", "end"):
|
|
627
|
+
return replace(e, align_self=Align.END)
|
|
628
|
+
if vl == "stretch":
|
|
629
|
+
return replace(e, align_self=Align.STRETCH)
|
|
630
|
+
return invalid()
|
|
631
|
+
if name == "flex-wrap":
|
|
632
|
+
if vl == "wrap":
|
|
633
|
+
return replace(e, wrap=True)
|
|
634
|
+
if vl == "nowrap":
|
|
635
|
+
return replace(e, wrap=False)
|
|
636
|
+
return unsupported(f"CSS: 'flex-wrap: {vl}' is not supported (use wrap or nowrap)")
|
|
637
|
+
if name == "border-radius":
|
|
638
|
+
c = self._corners_of(v)
|
|
639
|
+
return invalid() if c is None else self._with_corners(e, c)
|
|
640
|
+
if name in ("border-top-left-radius", "border-top-right-radius",
|
|
641
|
+
"border-bottom-right-radius", "border-bottom-left-radius"):
|
|
642
|
+
l = ln()
|
|
643
|
+
if l is None or l.value < 0.0:
|
|
644
|
+
return invalid()
|
|
645
|
+
c = e.corners or Corners(e.border_radius, e.border_radius, e.border_radius, e.border_radius)
|
|
646
|
+
field = {"border-top-left-radius": "top_left", "border-top-right-radius": "top_right",
|
|
647
|
+
"border-bottom-right-radius": "bottom_right"}.get(name, "bottom_left")
|
|
648
|
+
return self._with_corners(e, replace(c, **{field: l}))
|
|
649
|
+
if name == "border":
|
|
650
|
+
if vl in ("none", "0"):
|
|
651
|
+
return replace(e, border_width=Length.ZERO)
|
|
652
|
+
width = color = None
|
|
653
|
+
no_border = False
|
|
654
|
+
ok = True
|
|
655
|
+
for tok in _split_ws(v):
|
|
656
|
+
tl = tok.lower()
|
|
657
|
+
w = C.length(tok)
|
|
658
|
+
if w is not None:
|
|
659
|
+
width = w
|
|
660
|
+
elif tl in ("none", "hidden"):
|
|
661
|
+
no_border = True
|
|
662
|
+
elif tl in _BORDER_STYLES:
|
|
663
|
+
pass
|
|
664
|
+
else:
|
|
665
|
+
c = C.color(tok)
|
|
666
|
+
if c is not None:
|
|
667
|
+
color = c
|
|
668
|
+
else:
|
|
669
|
+
ok = False
|
|
670
|
+
if not ok:
|
|
671
|
+
return invalid()
|
|
672
|
+
r = replace(e, border_width=Length.ZERO if no_border else (width or Length.px(3.0)))
|
|
673
|
+
return replace(r, border_color=color) if color is not None else r
|
|
674
|
+
if name == "display":
|
|
675
|
+
if vl in ("flex", "block"):
|
|
676
|
+
return e
|
|
677
|
+
return unsupported(f"CSS: 'display: {vl}' is not supported in v1 (use display:flex)")
|
|
678
|
+
if name == "flex-direction":
|
|
679
|
+
if vl == "row":
|
|
680
|
+
return replace(e, direction=Direction.ROW)
|
|
681
|
+
if vl == "column":
|
|
682
|
+
return replace(e, direction=Direction.COLUMN)
|
|
683
|
+
return unsupported(f"CSS: 'flex-direction: {vl}' is not supported in v1 (use row or column)")
|
|
684
|
+
if name == "flex":
|
|
685
|
+
if vl == "none":
|
|
686
|
+
g = 0.0
|
|
687
|
+
elif vl == "auto":
|
|
688
|
+
g = 1.0
|
|
689
|
+
else:
|
|
690
|
+
g = _to_float(vl.split(" ")[0])
|
|
691
|
+
return replace(e, flex=g) if g is not None and g >= 0.0 else invalid()
|
|
692
|
+
if name == "justify-content":
|
|
693
|
+
m = {"flex-start": Justify.START, "start": Justify.START, "center": Justify.CENTER,
|
|
694
|
+
"flex-end": Justify.END, "end": Justify.END, "space-between": Justify.BETWEEN,
|
|
695
|
+
"space-around": Justify.AROUND, "space-evenly": Justify.EVENLY}
|
|
696
|
+
if vl in m:
|
|
697
|
+
return replace(e, justify=m[vl])
|
|
698
|
+
return unsupported(f"CSS: 'justify-content: {vl}' is not supported (use flex-start, "
|
|
699
|
+
"center, flex-end, space-between, space-around or space-evenly)")
|
|
700
|
+
if name == "position":
|
|
701
|
+
if vl in ("static", "relative"):
|
|
702
|
+
return replace(e, position=Position.STATIC)
|
|
703
|
+
if vl == "absolute":
|
|
704
|
+
return replace(e, position=Position.ABSOLUTE)
|
|
705
|
+
return unsupported(f"CSS: 'position: {vl}' is not supported in v1 (use static, relative or absolute)")
|
|
706
|
+
if name in ("top", "right", "bottom", "left"):
|
|
707
|
+
if vl == "auto":
|
|
708
|
+
return replace(e, **{name: None})
|
|
709
|
+
l = ln()
|
|
710
|
+
return invalid() if l is None else replace(e, **{name: l})
|
|
711
|
+
if name == "z-index":
|
|
712
|
+
if vl == "auto":
|
|
713
|
+
return replace(e, z_index=0)
|
|
714
|
+
n = _int_or_none(vl)
|
|
715
|
+
return invalid() if n is None else replace(e, z_index=max(-1000, min(1000, n)))
|
|
716
|
+
if name == "background-size":
|
|
717
|
+
m = {"cover": ImageFit.COVER, "contain": ImageFit.CONTAIN,
|
|
718
|
+
"100% 100%": ImageFit.FILL, "fill": ImageFit.FILL}
|
|
719
|
+
if vl in m:
|
|
720
|
+
return replace(e, background_size=m[vl])
|
|
721
|
+
return unsupported(f"CSS: 'background-size: {vl}' is not supported in v1 "
|
|
722
|
+
"(use cover, contain or 100% 100%)")
|
|
723
|
+
if name == "background-position":
|
|
724
|
+
m = {"center": ImagePos.CENTER, "center center": ImagePos.CENTER,
|
|
725
|
+
"top": ImagePos.TOP, "top center": ImagePos.TOP, "center top": ImagePos.TOP,
|
|
726
|
+
"bottom": ImagePos.BOTTOM, "bottom center": ImagePos.BOTTOM, "center bottom": ImagePos.BOTTOM,
|
|
727
|
+
"left": ImagePos.LEFT, "left center": ImagePos.LEFT, "center left": ImagePos.LEFT,
|
|
728
|
+
"right": ImagePos.RIGHT, "right center": ImagePos.RIGHT, "center right": ImagePos.RIGHT}
|
|
729
|
+
if vl in m:
|
|
730
|
+
return replace(e, background_position=m[vl])
|
|
731
|
+
return unsupported(f"CSS: 'background-position: {vl}' is not supported in v1 "
|
|
732
|
+
"(use center, top, bottom, left or right)")
|
|
733
|
+
if name == "object-fit":
|
|
734
|
+
m = {"cover": ImageFit.COVER, "contain": ImageFit.CONTAIN, "scale-down": ImageFit.CONTAIN,
|
|
735
|
+
"fill": ImageFit.FILL}
|
|
736
|
+
if vl in m:
|
|
737
|
+
return replace(e, object_fit=m[vl])
|
|
738
|
+
return unsupported(f"CSS: 'object-fit: {vl}' is not supported in v1 (use cover, contain or fill)")
|
|
739
|
+
if name == "dock":
|
|
740
|
+
m = {"bottom": Dock.BOTTOM, "top": Dock.TOP, "left": Dock.LEFT,
|
|
741
|
+
"right": Dock.RIGHT, "center": Dock.CENTER}
|
|
742
|
+
return replace(e, dock=m[vl]) if vl in m else invalid()
|
|
743
|
+
if name == "align-items":
|
|
744
|
+
m = {"flex-start": Align.START, "start": Align.START, "center": Align.CENTER,
|
|
745
|
+
"flex-end": Align.END, "end": Align.END, "stretch": Align.STRETCH}
|
|
746
|
+
if vl in m:
|
|
747
|
+
return replace(e, align_items=m[vl])
|
|
748
|
+
return unsupported(f"CSS: 'align-items: {vl}' is not supported in v1 (use flex-start, center or flex-end)")
|
|
749
|
+
if name == "gap":
|
|
750
|
+
l = ln()
|
|
751
|
+
return invalid() if l is None else replace(e, gap=l)
|
|
752
|
+
if name in ("width", "height"):
|
|
753
|
+
s = C.size(v)
|
|
754
|
+
return invalid() if s is None else replace(e, **{name: s})
|
|
755
|
+
if name == "animation":
|
|
756
|
+
a = self._animation_of(v)
|
|
757
|
+
if a is not None:
|
|
758
|
+
return replace(e, animation=a)
|
|
759
|
+
return unsupported(f"CSS: invalid value in 'animation: {d.value}' (use none, fade, slide-up "
|
|
760
|
+
"or slide-left, then an optional time like 200ms), ignored")
|
|
761
|
+
return unsupported(f"CSS: property '{d.name}' is not supported in v1 and was ignored")
|
|
762
|
+
|
|
763
|
+
# -- text -----------------------------------------------------------
|
|
764
|
+
def _is_inline(self, el: HtmlElement) -> bool:
|
|
765
|
+
return (el.tag == "span" and el.id is None and "class" not in el.attributes
|
|
766
|
+
and not any(isinstance(c, HtmlElement) and not self._is_inline(c) for c in el.children))
|
|
767
|
+
|
|
768
|
+
@staticmethod
|
|
769
|
+
def _span(text: str, e: NativeStyle) -> Span:
|
|
770
|
+
return Span(_transformed(text, e.text_transform), e.bold, e.text_color, e.font_size,
|
|
771
|
+
e.italic, e.underline, e.strike)
|
|
772
|
+
|
|
773
|
+
def _collect_inline(self, el: HtmlElement, parent_style: NativeStyle, run: list[Span]) -> None:
|
|
774
|
+
style = self._compute_style(el, parent_style)
|
|
775
|
+
for f in el.children:
|
|
776
|
+
if isinstance(f, HtmlText):
|
|
777
|
+
run.append(self._span(f.text, style))
|
|
778
|
+
elif isinstance(f, HtmlElement):
|
|
779
|
+
self._collect_inline(f, style, run)
|
|
780
|
+
|
|
781
|
+
@staticmethod
|
|
782
|
+
def _normalize(run: list[Span]) -> list[Span]:
|
|
783
|
+
result: list[Span] = []
|
|
784
|
+
last_space = True
|
|
785
|
+
for t in run:
|
|
786
|
+
sb = []
|
|
787
|
+
for ch in t.text:
|
|
788
|
+
if ch in " \t\n\r":
|
|
789
|
+
if not last_space:
|
|
790
|
+
sb.append(" ")
|
|
791
|
+
last_space = True
|
|
792
|
+
else:
|
|
793
|
+
sb.append(ch)
|
|
794
|
+
last_space = False
|
|
795
|
+
if sb:
|
|
796
|
+
result.append(replace(t, text="".join(sb)))
|
|
797
|
+
if result:
|
|
798
|
+
u = result[-1]
|
|
799
|
+
cleaned = u.text.rstrip(" ")
|
|
800
|
+
if not cleaned:
|
|
801
|
+
result.pop()
|
|
802
|
+
elif len(cleaned) != len(u.text):
|
|
803
|
+
result[-1] = replace(u, text=cleaned)
|
|
804
|
+
return result
|
|
805
|
+
|
|
806
|
+
def _text_of(self, el: HtmlElement, style: NativeStyle) -> str:
|
|
807
|
+
run: list[Span] = []
|
|
808
|
+
for f in el.children:
|
|
809
|
+
if isinstance(f, HtmlText):
|
|
810
|
+
run.append(self._span(f.text, style))
|
|
811
|
+
elif isinstance(f, HtmlElement):
|
|
812
|
+
if self._is_inline(f):
|
|
813
|
+
self._collect_inline(f, style, run)
|
|
814
|
+
else:
|
|
815
|
+
raise self._err(f"<{el.tag}> can only contain text", f.line, f"<{f.tag}")
|
|
816
|
+
return "".join(s.text for s in self._normalize(run))
|
|
817
|
+
|
|
818
|
+
# -- children ----------------------------------------------------------
|
|
819
|
+
def _convert_children(self, nodes: list[HtmlNode], parent_style: NativeStyle, owner: str) -> list[Native]:
|
|
820
|
+
saved = self.parent_tag
|
|
821
|
+
self.parent_tag = owner
|
|
822
|
+
self.depth += 1
|
|
823
|
+
try:
|
|
824
|
+
return self._convert_children_of(nodes, parent_style)
|
|
825
|
+
finally:
|
|
826
|
+
self.depth -= 1
|
|
827
|
+
self.parent_tag = saved
|
|
828
|
+
|
|
829
|
+
def _convert_children_of(self, nodes: list[HtmlNode], parent_style: NativeStyle) -> list[Native]:
|
|
830
|
+
result: list[Native] = []
|
|
831
|
+
run: list[Span] = []
|
|
832
|
+
run_line = -1
|
|
833
|
+
|
|
834
|
+
def flush():
|
|
835
|
+
nonlocal run_line
|
|
836
|
+
spans = self._normalize(run)
|
|
837
|
+
if spans:
|
|
838
|
+
result.append(NativeText(id=None, classes=[], style=_inherit(parent_style),
|
|
839
|
+
line=run_line, spans=spans))
|
|
840
|
+
run.clear()
|
|
841
|
+
run_line = -1
|
|
842
|
+
|
|
843
|
+
for n in nodes:
|
|
844
|
+
if isinstance(n, HtmlText):
|
|
845
|
+
if run_line < 0:
|
|
846
|
+
run_line = n.line
|
|
847
|
+
run.append(self._span(n.text, parent_style))
|
|
848
|
+
elif isinstance(n, HtmlElement):
|
|
849
|
+
if self._is_inline(n):
|
|
850
|
+
if run_line < 0:
|
|
851
|
+
run_line = n.line
|
|
852
|
+
self._collect_inline(n, parent_style, run)
|
|
853
|
+
else:
|
|
854
|
+
flush()
|
|
855
|
+
result.append(self._convert_element(n, parent_style))
|
|
856
|
+
flush()
|
|
857
|
+
return result
|
|
858
|
+
|
|
859
|
+
def _register_id(self, el: HtmlElement) -> str | None:
|
|
860
|
+
i = el.id
|
|
861
|
+
if i is None:
|
|
862
|
+
return None
|
|
863
|
+
if i in self.ids or i in self.reserved_ids:
|
|
864
|
+
raise self._err(f"duplicate id '{i}'", el.line, i)
|
|
865
|
+
self.reserved_ids.add(i)
|
|
866
|
+
return i
|
|
867
|
+
|
|
868
|
+
# -- elements -------------------------------------------------------
|
|
869
|
+
def _convert_element(self, el: HtmlElement, parent_style: NativeStyle) -> Native:
|
|
870
|
+
tag = el.tag
|
|
871
|
+
if tag not in _SUPPORTED_TAGS:
|
|
872
|
+
raise self._err(f"tag '{tag}' is not supported", el.line, f"<{tag}", _TAGS_SUGGESTION)
|
|
873
|
+
my_depth = self.depth
|
|
874
|
+
my_parent = self.parent_tag
|
|
875
|
+
style = self._fix_position(el, self._compute_style(el, parent_style), my_depth)
|
|
876
|
+
id_ = self._register_id(el)
|
|
877
|
+
classes = el.classes
|
|
878
|
+
disabled = "disabled" in el.attributes or self.group_disabled
|
|
879
|
+
line = el.line
|
|
880
|
+
|
|
881
|
+
if tag == "button":
|
|
882
|
+
node: Native = NativeButton(id=id_, classes=classes, style=style, line=line,
|
|
883
|
+
label=self._text_of(el, style), disabled=disabled)
|
|
884
|
+
elif tag == "input":
|
|
885
|
+
node = self._convert_input(el, style, id_, classes, disabled)
|
|
886
|
+
elif tag == "textarea":
|
|
887
|
+
content = "".join(c.text for c in el.children if isinstance(c, HtmlText))
|
|
888
|
+
if content.startswith("\n"):
|
|
889
|
+
content = content[1:]
|
|
890
|
+
rows_raw = el.attributes.get("rows")
|
|
891
|
+
rows_i = _int_or_none(rows_raw.strip()) if rows_raw is not None else None
|
|
892
|
+
rows = max(rows_i, 1) if rows_i is not None else 3
|
|
893
|
+
node = NativeField(id=id_, classes=classes, style=style, line=line, type=FieldType.AREA,
|
|
894
|
+
hint=el.attributes.get("placeholder", ""), value=content,
|
|
895
|
+
disabled=disabled, read_only="readonly" in el.attributes, rows=rows)
|
|
896
|
+
elif tag == "img":
|
|
897
|
+
src = (el.attributes.get("src") or "").strip()
|
|
898
|
+
if not src:
|
|
899
|
+
raise self._err("<img> requires a src attribute", line, "<img")
|
|
900
|
+
icon = IconSet.name_of(src)
|
|
901
|
+
if icon is not None and IconSet.shapes_of(icon) is None:
|
|
902
|
+
raise self._err(f"icon '{icon}' does not exist", line, src,
|
|
903
|
+
"icons: " + ", ".join(IconSet.names()))
|
|
904
|
+
node = NativeImage(id=id_, classes=classes, style=style, line=line,
|
|
905
|
+
source=src, description=el.attributes.get("alt", ""))
|
|
906
|
+
elif tag == "ul":
|
|
907
|
+
node = self._convert_list(el, style, id_, classes)
|
|
908
|
+
elif tag == "li":
|
|
909
|
+
raise self._err("<li> can only be inside <ul>", line, "<li")
|
|
910
|
+
else:
|
|
911
|
+
node = self._convert_container(el, tag, style, id_, classes, my_depth, my_parent)
|
|
912
|
+
|
|
913
|
+
node.key_hint = _key_of(el)
|
|
914
|
+
node.scope = _scope_of(el)
|
|
915
|
+
if id_ is not None:
|
|
916
|
+
self.ids[id_] = node
|
|
917
|
+
return node
|
|
918
|
+
|
|
919
|
+
def _convert_container(self, el, tag, style, id_, classes, my_depth, my_parent) -> Native:
|
|
920
|
+
line = el.line
|
|
921
|
+
if tag == "legend" and my_parent != "fieldset":
|
|
922
|
+
raise self._err("<legend> can only be inside <fieldset>", line, "<legend")
|
|
923
|
+
if tag == "dialog" and my_depth != 1:
|
|
924
|
+
raise self._err("<dialog> must be a direct child of the root tag", line, "<dialog",
|
|
925
|
+
"put the <dialog> at the first level of the screen, next to the other blocks")
|
|
926
|
+
if tag == "header" and my_depth != 1:
|
|
927
|
+
raise self._err("<header> must be a direct child of the root tag", line, "<header",
|
|
928
|
+
"put the <header> at the first level of the screen; it stays fixed at the top")
|
|
929
|
+
if tag == "footer" and my_depth != 1:
|
|
930
|
+
raise self._err("<footer> must be a direct child of the root tag", line, "<footer",
|
|
931
|
+
"put the <footer> at the first level of the screen; it stays fixed outside the scroll")
|
|
932
|
+
saved_group = self.group_disabled
|
|
933
|
+
if tag == "fieldset" and "disabled" in el.attributes:
|
|
934
|
+
self.group_disabled = True
|
|
935
|
+
try:
|
|
936
|
+
found = self._convert_children(el.children, style, tag)
|
|
937
|
+
finally:
|
|
938
|
+
self.group_disabled = saved_group
|
|
939
|
+
|
|
940
|
+
def special(n):
|
|
941
|
+
return isinstance(n, NativeBox) and (n.header or n.footer or n.overlay)
|
|
942
|
+
|
|
943
|
+
if my_depth == 0 and any(special(n) for n in found):
|
|
944
|
+
headers = [n for n in found if isinstance(n, NativeBox) and n.header]
|
|
945
|
+
if len(headers) > 1:
|
|
946
|
+
raise self._err("a screen can have only one <header>", headers[1].line, "<header")
|
|
947
|
+
footers = [n for n in found if isinstance(n, NativeBox) and n.footer]
|
|
948
|
+
if len(footers) > 1:
|
|
949
|
+
raise self._err("a screen can have only one <footer>", footers[1].line, "<footer")
|
|
950
|
+
children = (headers + [n for n in found if not special(n)] + footers
|
|
951
|
+
+ [n for n in found if isinstance(n, NativeBox) and n.overlay])
|
|
952
|
+
else:
|
|
953
|
+
children = found
|
|
954
|
+
virtual = "virtual" in el.attributes
|
|
955
|
+
if virtual:
|
|
956
|
+
for c in children:
|
|
957
|
+
if isinstance(c, NativeBox) and c.overlay:
|
|
958
|
+
raise self._err("<dialog> cannot be inside a 'virtual' container", c.line, "<dialog")
|
|
959
|
+
if c.style.position == Position.ABSOLUTE:
|
|
960
|
+
raise self._err("'position: absolute' cannot be used inside a 'virtual' container", c.line)
|
|
961
|
+
only = children[0] if len(children) == 1 else None
|
|
962
|
+
is_group = tag in ("dialog", "fieldset", "header", "footer")
|
|
963
|
+
if (not virtual and not is_group and isinstance(only, NativeText)
|
|
964
|
+
and only.style.position != Position.ABSOLUTE):
|
|
965
|
+
return NativeText(id=id_, classes=classes, style=style, line=line, spans=only.spans)
|
|
966
|
+
return NativeBox(id=id_, classes=classes, style=style, line=line, children=children,
|
|
967
|
+
virtual=virtual, overlay=tag == "dialog", header=tag == "header",
|
|
968
|
+
footer=tag == "footer",
|
|
969
|
+
has_absolute=not virtual and any(c.style.position == Position.ABSOLUTE for c in children))
|
|
970
|
+
|
|
971
|
+
def _convert_input(self, el, style, id_, classes, disabled) -> Native:
|
|
972
|
+
type_ = (el.attributes.get("type") or "text").strip().lower()
|
|
973
|
+
if type_ == "checkbox":
|
|
974
|
+
return NativeCheckbox(id=id_, classes=classes, style=style, line=el.line,
|
|
975
|
+
checked="checked" in el.attributes, disabled=disabled)
|
|
976
|
+
if type_ in ("text", "password", "number"):
|
|
977
|
+
ft = {"password": FieldType.PASSWORD, "number": FieldType.NUMBER}.get(type_, FieldType.TEXT)
|
|
978
|
+
return NativeField(id=id_, classes=classes, style=style, line=el.line, type=ft,
|
|
979
|
+
hint=el.attributes.get("placeholder", ""),
|
|
980
|
+
value=el.attributes.get("value", ""), disabled=disabled,
|
|
981
|
+
read_only="readonly" in el.attributes, rows=1)
|
|
982
|
+
raise self._err(f'<input type="{type_}"> is not supported', el.line, "type",
|
|
983
|
+
"supported types: text, password, number, checkbox")
|
|
984
|
+
|
|
985
|
+
def _convert_list(self, el, style, id_, classes) -> NativeList:
|
|
986
|
+
items: list[NativeItem] = []
|
|
987
|
+
for f in el.children:
|
|
988
|
+
if isinstance(f, HtmlText):
|
|
989
|
+
if not _blank(f.text):
|
|
990
|
+
raise self._err("<ul> can only contain <li>", f.line)
|
|
991
|
+
elif isinstance(f, HtmlElement):
|
|
992
|
+
if f.tag != "li":
|
|
993
|
+
raise self._err(f"<ul> can only contain <li>, found <{f.tag}>", f.line, f"<{f.tag}")
|
|
994
|
+
li_style = self._fix_position(f, self._compute_style(f, style), 2)
|
|
995
|
+
li_id = self._register_id(f)
|
|
996
|
+
item = NativeItem(id=li_id, classes=f.classes, style=li_style, line=f.line,
|
|
997
|
+
marker="\u2022",
|
|
998
|
+
children=self._convert_children(f.children, li_style, "li"))
|
|
999
|
+
item.key_hint = _key_of(f)
|
|
1000
|
+
item.scope = _scope_of(f)
|
|
1001
|
+
if li_id is not None:
|
|
1002
|
+
self.ids[li_id] = item
|
|
1003
|
+
items.append(item)
|
|
1004
|
+
return NativeList(id=id_, classes=classes, style=style, line=el.line, items=items)
|