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,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)