euporie 2.3.2__py3-none-any.whl → 2.4.1__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.
Files changed (92) hide show
  1. euporie/console/__main__.py +3 -1
  2. euporie/console/app.py +6 -4
  3. euporie/console/tabs/console.py +34 -9
  4. euporie/core/__init__.py +6 -1
  5. euporie/core/__main__.py +1 -1
  6. euporie/core/app.py +79 -109
  7. euporie/core/border.py +44 -14
  8. euporie/core/comm/base.py +5 -4
  9. euporie/core/comm/ipywidgets.py +11 -11
  10. euporie/core/comm/registry.py +12 -6
  11. euporie/core/commands.py +30 -23
  12. euporie/core/completion.py +1 -4
  13. euporie/core/config.py +15 -5
  14. euporie/core/convert/{base.py → core.py} +117 -53
  15. euporie/core/convert/formats/ansi.py +46 -25
  16. euporie/core/convert/formats/base64.py +3 -3
  17. euporie/core/convert/formats/common.py +38 -13
  18. euporie/core/convert/formats/formatted_text.py +54 -12
  19. euporie/core/convert/formats/html.py +5 -5
  20. euporie/core/convert/formats/jpeg.py +1 -1
  21. euporie/core/convert/formats/markdown.py +4 -4
  22. euporie/core/convert/formats/pdf.py +1 -1
  23. euporie/core/convert/formats/pil.py +5 -3
  24. euporie/core/convert/formats/png.py +7 -6
  25. euporie/core/convert/formats/rich.py +4 -3
  26. euporie/core/convert/formats/sixel.py +5 -5
  27. euporie/core/convert/utils.py +1 -1
  28. euporie/core/current.py +11 -5
  29. euporie/core/formatted_text/ansi.py +4 -8
  30. euporie/core/formatted_text/html.py +1630 -856
  31. euporie/core/formatted_text/markdown.py +177 -166
  32. euporie/core/formatted_text/table.py +20 -14
  33. euporie/core/formatted_text/utils.py +21 -10
  34. euporie/core/io.py +14 -14
  35. euporie/core/kernel.py +48 -37
  36. euporie/core/key_binding/bindings/micro.py +5 -1
  37. euporie/core/key_binding/bindings/mouse.py +2 -2
  38. euporie/core/keys.py +3 -0
  39. euporie/core/launch.py +5 -2
  40. euporie/core/lexers.py +13 -2
  41. euporie/core/log.py +135 -139
  42. euporie/core/margins.py +32 -14
  43. euporie/core/path.py +273 -0
  44. euporie/core/processors.py +35 -0
  45. euporie/core/renderer.py +21 -5
  46. euporie/core/style.py +34 -19
  47. euporie/core/tabs/base.py +101 -17
  48. euporie/core/tabs/notebook.py +72 -30
  49. euporie/core/terminal.py +56 -48
  50. euporie/core/utils.py +12 -16
  51. euporie/core/widgets/cell.py +6 -5
  52. euporie/core/widgets/cell_outputs.py +2 -2
  53. euporie/core/widgets/decor.py +74 -82
  54. euporie/core/widgets/dialog.py +132 -28
  55. euporie/core/widgets/display.py +76 -24
  56. euporie/core/widgets/file_browser.py +87 -31
  57. euporie/core/widgets/formatted_text_area.py +1 -3
  58. euporie/core/widgets/forms.py +79 -40
  59. euporie/core/widgets/inputs.py +23 -13
  60. euporie/core/widgets/layout.py +4 -3
  61. euporie/core/widgets/menu.py +368 -216
  62. euporie/core/widgets/page.py +99 -58
  63. euporie/core/widgets/pager.py +1 -1
  64. euporie/core/widgets/palette.py +30 -27
  65. euporie/core/widgets/search_bar.py +38 -25
  66. euporie/core/widgets/status_bar.py +103 -5
  67. euporie/data/desktop/euporie-console.desktop +7 -0
  68. euporie/data/desktop/euporie-notebook.desktop +7 -0
  69. euporie/hub/__main__.py +3 -1
  70. euporie/hub/app.py +9 -7
  71. euporie/notebook/__main__.py +3 -1
  72. euporie/notebook/app.py +7 -30
  73. euporie/notebook/tabs/__init__.py +7 -3
  74. euporie/notebook/tabs/display.py +18 -9
  75. euporie/notebook/tabs/edit.py +106 -23
  76. euporie/notebook/tabs/json.py +73 -0
  77. euporie/notebook/tabs/log.py +18 -8
  78. euporie/notebook/tabs/notebook.py +60 -41
  79. euporie/preview/__main__.py +3 -1
  80. euporie/preview/app.py +2 -1
  81. euporie/preview/tabs/notebook.py +23 -10
  82. euporie/web/tabs/web.py +149 -0
  83. euporie/web/widgets/webview.py +563 -0
  84. euporie-2.4.1.data/data/share/applications/euporie-console.desktop +7 -0
  85. euporie-2.4.1.data/data/share/applications/euporie-notebook.desktop +7 -0
  86. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/METADATA +6 -5
  87. euporie-2.4.1.dist-info/RECORD +129 -0
  88. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/WHEEL +1 -1
  89. euporie/core/url.py +0 -64
  90. euporie-2.3.2.dist-info/RECORD +0 -122
  91. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/entry_points.txt +0 -0
  92. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -7,15 +7,20 @@ import re
7
7
  from ast import literal_eval
8
8
  from bisect import bisect_right
9
9
  from collections.abc import Mapping
10
- from functools import cached_property, lru_cache
10
+ from functools import cached_property, lru_cache, partial
11
11
  from html.parser import HTMLParser
12
12
  from itertools import zip_longest
13
13
  from math import ceil
14
- from typing import TYPE_CHECKING, NamedTuple
14
+ from operator import eq, ge, gt, le, lt
15
+ from typing import TYPE_CHECKING, NamedTuple, cast
16
+ from urllib.parse import urljoin
15
17
 
16
18
  from flatlatex.data import subscript, superscript
17
19
  from prompt_toolkit.application.current import get_app_session
18
- from prompt_toolkit.cache import SimpleCache
20
+ from prompt_toolkit.data_structures import Size
21
+ from prompt_toolkit.filters.base import Condition
22
+ from prompt_toolkit.filters.utils import _always as always
23
+ from prompt_toolkit.filters.utils import _never as never
19
24
  from prompt_toolkit.formatted_text.base import StyleAndTextTuples
20
25
  from prompt_toolkit.formatted_text.utils import split_lines
21
26
  from prompt_toolkit.layout.dimension import Dimension
@@ -42,7 +47,7 @@ from euporie.core.border import (
42
47
  UpperRightHalfDottedLine,
43
48
  UpperRightHalfLine,
44
49
  )
45
- from euporie.core.convert.base import convert, get_format
50
+ from euporie.core.convert.core import convert, get_format
46
51
  from euporie.core.convert.utils import data_pixel_size, pixels_to_cell_size
47
52
  from euporie.core.current import get_app
48
53
  from euporie.core.data_structures import DiBool, DiInt, DiStr
@@ -62,10 +67,8 @@ from euporie.core.formatted_text.utils import (
62
67
  pad,
63
68
  paste,
64
69
  strip,
65
- strip_one_trailing_newline,
66
70
  truncate,
67
71
  )
68
- from euporie.core.url import load_url
69
72
 
70
73
 
71
74
  class CssSelector(NamedTuple):
@@ -86,15 +89,16 @@ class CssSelector(NamedTuple):
86
89
 
87
90
 
88
91
  if TYPE_CHECKING:
89
- from typing import (
90
- Any,
91
- Callable,
92
- Generator,
93
- Hashable,
94
- Iterator,
95
- )
92
+ from pathlib import Path
93
+ from typing import Any, Callable, Generator, Iterator
94
+
95
+ from prompt_toolkit.filters.base import Filter
96
+ from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
97
+ from prompt_toolkit.mouse_events import MouseEvent
96
98
 
97
- CssSelectors = dict[tuple[tuple[CssSelector, ...], ...], dict[str, str]]
99
+ CssSelectors = dict[
100
+ Filter, dict[tuple[tuple[CssSelector, ...], ...], dict[str, str]]
101
+ ]
98
102
 
99
103
  log = logging.getLogger(__name__)
100
104
 
@@ -119,6 +123,38 @@ _SELECTOR_RE = re.compile(
119
123
  re.VERBOSE,
120
124
  )
121
125
 
126
+ _AT_RULE_RE = re.compile(
127
+ r"""
128
+ (
129
+ @(?:color-profile|container|counter-style|document|font-face|font-feature-values|font-palette-values|keyframes|layer|media|page|supports)
130
+ [^@{]+
131
+ (?:
132
+ {[^{}]*}
133
+ |
134
+ {(?:[^{]+?{(?:[^{}]|{[^}]+?})+?}\s*)*?}
135
+ )
136
+ |
137
+ @(?:charset|import|namespace)[^;]*;
138
+ )
139
+ """,
140
+ re.VERBOSE,
141
+ )
142
+
143
+ _NESTED_AT_RULE_RE = re.compile(
144
+ r"@(?P<identifier>[\w-]+)\s+(?P<rule>[^{]*?)\s*{\s*(?P<part>.*)\s*}"
145
+ )
146
+
147
+ _MEDIA_QUERY_TARGET_RE = re.compile(
148
+ r"""
149
+ (?:
150
+ (?P<invert>not\s+)?
151
+ (?:only\s+)? # Ignore this
152
+ (?P<type>all|print|screen) |
153
+ (?:\((?P<feature>[^)]+)\))
154
+ )
155
+ """,
156
+ re.VERBOSE,
157
+ )
122
158
 
123
159
  # List of elements which might not have a close tag
124
160
  _VOID_ELEMENTS = (
@@ -330,6 +366,10 @@ def match_css_selector(
330
366
  or (rule == "even" and sibling_element_index % 2 == 0)
331
367
  )
332
368
  continue
369
+ if pseudo.startswith(":link"):
370
+ pseudo = pseudo[5:]
371
+ matched = bool(element_attrs.get("href"))
372
+ continue
333
373
  return False
334
374
 
335
375
  if not matched:
@@ -443,7 +483,7 @@ def get_color(value: str) -> str:
443
483
  hexes = []
444
484
  for color_value in color_values:
445
485
  if (int_value := get_integer(color_value)) is not None:
446
- hexes.append(hex(int_value)[2:])
486
+ hexes.append(f"{hex(int_value)[2:]:02}")
447
487
  else:
448
488
  return ""
449
489
  return "#" + "".join(hexes)
@@ -504,7 +544,7 @@ def css_dimension(
504
544
  # TODO - process view-width units
505
545
  # https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Values_and_units
506
546
 
507
- if units == "%":
547
+ if units in {"%", "vh", "vw"}:
508
548
  assert available is not None
509
549
  return number / 100 * available
510
550
 
@@ -541,7 +581,7 @@ def parse_css_content(content: str) -> dict[str, str]:
541
581
 
542
582
  for declaration in content.split(";"):
543
583
  name, _, value = declaration.partition(":")
544
- name = name.strip()
584
+ name = name.strip().lower()
545
585
 
546
586
  # Ignore "!important" tags for now - TODO
547
587
  value = value.replace("!important", "").strip()
@@ -561,6 +601,8 @@ def parse_css_content(content: str) -> dict[str, str]:
561
601
  bottom = values[2]
562
602
  elif len(values) >= 4:
563
603
  top, right, bottom, left, *_ = values
604
+ else:
605
+ top = right = bottom = left = ""
564
606
  return top, right, bottom, left
565
607
 
566
608
  # Compute values
@@ -659,6 +701,15 @@ def parse_css_content(content: str) -> dict[str, str]:
659
701
  elif each_value in _LIST_STYLE_TYPES:
660
702
  theme["list_style_type"] = each_value
661
703
 
704
+ elif name == "flex-flow":
705
+ values = set(value.split(" "))
706
+ directions = {"row", "row-reverse", "column", "column-reverse"}
707
+ wraps = {"nowrap", "wrap", "wrap-reverse"}
708
+ for value in values.intersection(directions):
709
+ theme["flex_direction"] = value
710
+ for value in values.intersection(wraps):
711
+ theme["flex_wrap"] = value
712
+
662
713
  else:
663
714
  name = name.replace("-", "_")
664
715
  theme[name] = value
@@ -723,12 +774,15 @@ _DEFAULT_ELEMENT_CSS = {
723
774
  "border_left_color": "",
724
775
  "border_bottom_color": "",
725
776
  "border_right_color": "",
777
+ # Flex
778
+ "flex_direction": "row",
779
+ "align_content": "normal",
726
780
  # Position
727
781
  "position": "static",
728
- "top": "0",
729
- "right": "0",
730
- "bottom": "0",
731
- "left": "0",
782
+ "top": "unset",
783
+ "right": "unset",
784
+ "bottom": "unset",
785
+ "left": "unset",
732
786
  "z_index": "0",
733
787
  # Lists
734
788
  "list_style_type": "none",
@@ -771,8 +825,21 @@ class Theme(Mapping):
771
825
  available_height: int,
772
826
  ) -> None:
773
827
  """Set the space available to the element for rendering."""
774
- self.available_width = available_width
775
- self.available_height = available_height
828
+ if self.theme["position"] == "fixed":
829
+ # Space is given by position
830
+ position = self.position
831
+ dom = self.element.dom
832
+ assert dom.width is not None and dom.height is not None
833
+ self.available_width = (dom.width - position.right) - position.left
834
+ self.available_height = (dom.height - position.bottom) - position.top
835
+
836
+ # elif parent_theme := self.parent_theme:
837
+ # self.available_width = parent_theme.content_width
838
+ # self.available_height = parent_theme.content_height
839
+
840
+ else:
841
+ self.available_width = available_width
842
+ self.available_height = available_height
776
843
 
777
844
  # Theme calculation methods
778
845
 
@@ -786,7 +853,7 @@ class Theme(Mapping):
786
853
  **parent_theme.inherited_browser_css_theme,
787
854
  **parent_theme.browser_css_theme,
788
855
  }.items()
789
- if k in _HERITABLE_PROPS
856
+ if k in _HERITABLE_PROPS or k.startswith("__")
790
857
  }
791
858
 
792
859
  else:
@@ -795,22 +862,40 @@ class Theme(Mapping):
795
862
  @cached_property
796
863
  def inherited_theme(self) -> dict[str, str]:
797
864
  """Calculate the theme inherited from the element's parent."""
865
+ theme: dict[str, str] = {}
866
+ parent = self.element.parent
867
+
868
+ # Text elements inherit from direct inline parents
869
+ if self.element.name == "text" and parent is not None:
870
+ inline_parent_themes = []
871
+ while parent.theme.d_inline:
872
+ inline_parent_themes.append(parent.theme.theme)
873
+ parent = parent.parent
874
+ if parent is None:
875
+ break
876
+ for inherited_theme in inline_parent_themes:
877
+ theme.update(inherited_theme)
878
+
879
+ # Inherit heritable properties from the parent element's theme
880
+ # unless the default value is unset
798
881
  if (parent_theme := self.parent_theme) is not None:
799
882
  browser_css = self.browser_css_theme
800
- return {
801
- k: v
802
- for part in (
803
- parent_theme.inherited_theme,
804
- parent_theme.dom_css_theme,
805
- parent_theme.attributes_theme,
806
- parent_theme.style_attribute_theme,
807
- )
808
- for k, v in part.items()
809
- if k in _HERITABLE_PROPS and browser_css.get(k) != "unset"
810
- }
883
+ theme.update(
884
+ {
885
+ k: v
886
+ for part in (
887
+ parent_theme.inherited_theme,
888
+ parent_theme.dom_css_theme,
889
+ parent_theme.attributes_theme,
890
+ parent_theme.style_attribute_theme,
891
+ )
892
+ for k, v in part.items()
893
+ if (k in _HERITABLE_PROPS or k.startswith("__"))
894
+ and browser_css.get(k) != "unset"
895
+ }
896
+ )
811
897
 
812
- else:
813
- return {}
898
+ return theme
814
899
 
815
900
  @cached_property
816
901
  def style_attribute_theme(self) -> dict[str, str]:
@@ -855,70 +940,76 @@ class Theme(Mapping):
855
940
  """Calculate the theme defined in CSS."""
856
941
  specificity_rules = []
857
942
  element = self.element
858
- for selectors, rule in css.items():
859
- for selector_parts in selectors:
860
- # Last selector item should match the current element
861
- selector = selector_parts[-1]
862
- if not match_css_selector(
863
- selector.item or "",
864
- selector.attr or "",
865
- selector.pseudo or "",
866
- element.name,
867
- element.is_first_child_element,
868
- element.is_last_child_element,
869
- element.sibling_element_index,
870
- **element.attrs,
871
- ):
872
- continue
873
-
874
- # All of the parent selectors should match a separate parent in order
875
- # TODO - combinators
876
- # https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors#combinators
877
- unmatched_parents: list[Node] = [x for x in element.parents[::-1] if x]
878
-
879
- _unmatched_parents: list[Node]
880
- parent = element.parent
881
- if parent and (
882
- (selector.comb == ">" and parent)
883
- # Pseudo-element selectors only match direct ancestors
884
- or ((item := selector.item) and item.startswith("::"))
885
- ):
886
- _unmatched_parents = [parent]
887
- else:
888
- _unmatched_parents = unmatched_parents
889
-
890
- # TODO investigate caching element / selector chains so we don't have to
891
- # iterate through every parent every time
892
-
893
- # Iterate through selector items in reverse, skipping the last
894
- for selector in selector_parts[-2::-1]:
895
- for i, parent in enumerate(_unmatched_parents):
896
- if parent and match_css_selector(
943
+ for condition, css_block in css.items():
944
+ if condition():
945
+ for selectors, rule in css_block.items():
946
+ for selector_parts in selectors:
947
+ # Last selector item should match the current element
948
+ selector = selector_parts[-1]
949
+ if not match_css_selector(
897
950
  selector.item or "",
898
951
  selector.attr or "",
899
952
  selector.pseudo or "",
900
- parent.name,
901
- parent.is_first_child_element,
902
- parent.is_last_child_element,
903
- parent.sibling_element_index,
904
- **parent.attrs,
953
+ element.name,
954
+ element.is_first_child_element,
955
+ element.is_last_child_element,
956
+ element.sibling_element_index,
957
+ **element.attrs,
905
958
  ):
906
- if selector.comb == ">" and (parent := parent.parent):
907
- _unmatched_parents = [parent]
959
+ continue
960
+
961
+ # All of the parent selectors should match a separate parent in order
962
+ # TODO - combinators
963
+ # https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors#combinators
964
+ unmatched_parents: list[Node] = [
965
+ x for x in element.parents[::-1] if x
966
+ ]
967
+
968
+ _unmatched_parents: list[Node]
969
+ parent = element.parent
970
+ if parent and (
971
+ (selector.comb == ">" and parent)
972
+ # Pseudo-element selectors only match direct ancestors
973
+ or ((item := selector.item) and item.startswith("::"))
974
+ ):
975
+ _unmatched_parents = [parent]
976
+ else:
977
+ _unmatched_parents = unmatched_parents
978
+
979
+ # TODO investigate caching element / selector chains so we don't have to
980
+ # iterate through every parent every time
981
+
982
+ # Iterate through selector items in reverse, skipping the last
983
+ for selector in selector_parts[-2::-1]:
984
+ for i, parent in enumerate(_unmatched_parents):
985
+ if parent and match_css_selector(
986
+ selector.item or "",
987
+ selector.attr or "",
988
+ selector.pseudo or "",
989
+ parent.name,
990
+ parent.is_first_child_element,
991
+ parent.is_last_child_element,
992
+ parent.sibling_element_index,
993
+ **parent.attrs,
994
+ ):
995
+ if selector.comb == ">" and (
996
+ parent := parent.parent
997
+ ):
998
+ _unmatched_parents = [parent]
999
+ else:
1000
+ _unmatched_parents = unmatched_parents[i + 1 :]
1001
+ break
908
1002
  else:
909
- _unmatched_parents = unmatched_parents[i + 1 :]
910
- break
911
- else:
912
- break
1003
+ break
913
1004
 
914
- else:
915
- # Calculate selector specificity score
916
- specificity_rules.append(
917
- (selector_specificity(selector_parts), rule)
918
- )
919
- # We have already matched this rule, we don't need to keep checking
920
- # the rest of the selectors for this rule
921
- break
1005
+ else:
1006
+ # Calculate selector specificity score
1007
+ specificity_rules.append(
1008
+ (selector_specificity(selector_parts), rule)
1009
+ )
1010
+ # We have already matched this rule, we don't need to keep checking
1011
+ # the rest of the selectors for this rule
1012
+ break
922
1013
 
923
1014
  return {
924
1015
  k: v
@@ -941,32 +1032,79 @@ class Theme(Mapping):
941
1032
  @cached_property
942
1033
  def d_block(self) -> bool:
943
1034
  """If the element a block element."""
944
- return self.theme["display"] == "block" and self.theme["float"] == "none"
1035
+ theme = self.theme
1036
+ parent_theme = self.parent_theme
1037
+ return theme["display"] in {"block", "flex"} and (
1038
+ parent_theme is None
1039
+ or (self.floated is None and not parent_theme.d_flex)
1040
+ or (
1041
+ parent_theme.d_flex and parent_theme["flex_direction"].startswith("col")
1042
+ )
1043
+ )
945
1044
 
946
1045
  @cached_property
947
1046
  def d_inline(self) -> bool:
948
1047
  """If the element an inline element."""
949
- return self.theme["display"] == "inline" and self.theme["float"] == "none"
1048
+ return self.theme["display"] == "inline" and self.floated is None
950
1049
 
951
1050
  @cached_property
952
1051
  def d_inline_block(self) -> bool:
953
1052
  """If the element an inline element."""
954
- return self.theme["display"] == "inline-block" or self.theme["float"] != "none"
1053
+ theme = self.theme
1054
+ parent_theme = self.parent_theme
1055
+ return (
1056
+ # Is an actual inline block
1057
+ theme["display"] == "inline-block"
1058
+ # Is floated
1059
+ or self.floated is not None
1060
+ # If flexed in the row direction
1061
+ or (
1062
+ parent_theme is not None
1063
+ and parent_theme.d_flex
1064
+ and parent_theme["flex_direction"].startswith("row")
1065
+ )
1066
+ )
1067
+
1068
+ @cached_property
1069
+ def d_flex(self) -> bool:
1070
+ """If the element a block element."""
1071
+ return self.theme["display"] == "flex"
1072
+
1073
+ @cached_property
1074
+ def d_image(self) -> bool:
1075
+ """If the element is an image."""
1076
+ return self.element.name in {"img", "svg"}
955
1077
 
956
1078
  @cached_property
957
1079
  def d_table(self) -> bool:
958
1080
  """If the element a block element."""
959
1081
  return self.theme["display"] == "table"
960
1082
 
1083
+ @cached_property
1084
+ def d_table_cell(self) -> bool:
1085
+ """If the element a block element."""
1086
+ return self.theme["display"] == "table-cell"
1087
+
961
1088
  @cached_property
962
1089
  def d_list_item(self) -> bool:
963
1090
  """If the element an inline element."""
964
- return self.theme["display"] == "list-item" and self.theme["float"] == "none"
1091
+ return self.theme["display"] == "list-item" and self.floated is None
965
1092
 
966
1093
  @cached_property
967
1094
  def d_blocky(self) -> bool:
968
1095
  """If the element an inline element."""
969
- return self.d_block or self.d_table or self.d_list_item
1096
+ return self.d_block or self.d_table or self.d_table_cell or self.d_list_item
1097
+
1098
+ @cached_property
1099
+ def floated(self) -> str | None:
1100
+ """The float status of the element."""
1101
+ value = self.theme["float"]
1102
+ # Float property does not apply to flex-level boxes
1103
+ if value == "none" or (
1104
+ (parent_theme := self.parent_theme) and parent_theme.d_flex
1105
+ ):
1106
+ return None
1107
+ return value
970
1108
 
971
1109
  @property
972
1110
  def min_width(self) -> int | None:
@@ -982,17 +1120,24 @@ class Theme(Mapping):
982
1120
  @property
983
1121
  def width(self) -> int | None:
984
1122
  """The pescribed width."""
985
- if value := self.theme.get("width"):
986
- theme_width = css_dimension(
987
- value, vertical=False, available=self.available_width
988
- )
1123
+ value = self.theme.get("width")
1124
+
1125
+ theme_width: int | float | None
1126
+ if value:
1127
+ if value == "min-content":
1128
+ theme_width = self.min_content_width
1129
+ elif value == "max-content":
1130
+ theme_width = self.max_content_width
1131
+ else:
1132
+ theme_width = css_dimension(
1133
+ value, vertical=False, available=self.available_width
1134
+ )
989
1135
  if theme_width is not None:
990
- return int(theme_width)
1136
+ return min(int(theme_width), self.available_width)
991
1137
 
992
- else:
993
- element = self.element
1138
+ elif (element := self.element).name == "input":
994
1139
  attrs = element.attrs
995
- if element.name == "input" and attrs.get("type") in {
1140
+ if attrs.get("type") in {
996
1141
  "text",
997
1142
  "password",
998
1143
  "email",
@@ -1003,6 +1148,9 @@ class Theme(Mapping):
1003
1148
  }:
1004
1149
  return try_eval(size) if (size := attrs.get("size")) else 20
1005
1150
 
1151
+ elif element.name == "text":
1152
+ return len(element.text)
1153
+
1006
1154
  return None
1007
1155
 
1008
1156
  @property
@@ -1016,47 +1164,150 @@ class Theme(Mapping):
1016
1164
  return int(theme_width)
1017
1165
  return None
1018
1166
 
1019
- @property
1020
- def height(self) -> int:
1021
- """The perscribed height."""
1022
- # TODO - process min-/max-height
1023
- if value := self.get("height"):
1024
- theme_height = css_dimension(
1025
- value, vertical=True, available=self.available_height
1167
+ @cached_property
1168
+ def min_content_width(self) -> int:
1169
+ """Get maximum absolute child width."""
1170
+ if self.element.name == "text":
1171
+ return max([len(x) for x in self.element.text.split()] or [0])
1172
+ else:
1173
+ return max(
1174
+ [
1175
+ child.theme.min_content_width
1176
+ for child in self.element.renderable_contents
1177
+ ]
1178
+ or [0]
1179
+ )
1180
+
1181
+ @cached_property
1182
+ def max_content_width(self) -> int:
1183
+ """Get maximum absolute child width."""
1184
+ if self.element.name == "text":
1185
+ return max([len(x) for x in self.element.text.split("\n")] or [0])
1186
+ else:
1187
+ return sum(
1188
+ [
1189
+ child.theme.max_content_width
1190
+ for child in self.element.renderable_contents
1191
+ ]
1192
+ or [0]
1026
1193
  )
1027
- if theme_height is not None:
1028
- return int(theme_height)
1029
- assert self.available_height is not None
1030
- return self.available_height
1031
1194
 
1032
1195
  @property
1033
1196
  def content_width(self) -> int:
1034
1197
  """Return the width available for rendering the element's content."""
1035
1198
  value = self.width
1036
- # Set content width to the available width for blocks
1199
+
1200
+ border_box = self.theme.get("box_sizing") == "border-box"
1201
+
1202
+ # Use max-content-width as default for inline-blocks
1203
+ if value is None and self.d_inline_block and not self.d_image:
1204
+ value = self.max_content_width
1205
+ # Do not use border-box for inline-blocks if no width is given
1206
+ border_box = False
1207
+
1208
+ # If we do not have a value, use the available space
1037
1209
  if value is None:
1038
- value = (self.available_width or 0) - self.margin.left - self.margin.right
1210
+ margin = self.margin
1211
+ value = (self.available_width or 0) - margin.left - margin.right
1212
+
1213
+ # Blocks without a set with are rendered with border-box box-sizing
1214
+ if self.d_blocky or self.d_inline_block:
1215
+ border_box = True
1216
+
1217
+ # Ignore box-sizing for table cells, as they include padding and borders
1218
+ if self.d_table_cell:
1219
+ border_box = False
1220
+
1221
+ # Remove padding and borders from available space if we are using box-sizing
1222
+ if border_box:
1223
+ padding = self.padding
1224
+ border_visibility = self.border_visibility
1225
+ value -= padding.left + padding.right
1226
+ value -= border_visibility.left + border_visibility.right
1227
+
1039
1228
  # Apply min / max width constraints
1040
1229
  if (max_width := self.max_width) is not None and max_width < value:
1041
1230
  value = max_width
1042
1231
  elif (min_width := self.min_width) is not None and min_width > value:
1043
1232
  value = min_width
1044
- # Remove padding and borders from content width for blocks
1045
- if value and (self.d_blocky or self.d_inline_block):
1046
- value -= self.padding.left + self.padding.right
1047
- value -= self.border_visibility.left + self.border_visibility.right
1048
1233
 
1049
1234
  return max(0, value)
1050
1235
 
1236
+ @property
1237
+ def min_height(self) -> int | None:
1238
+ """The minimum permitted height."""
1239
+ if value := self.get("min_height"):
1240
+ theme_height = css_dimension(
1241
+ value, vertical=True, available=self.available_height
1242
+ )
1243
+ if theme_height is not None:
1244
+ return int(theme_height)
1245
+ return None
1246
+
1247
+ @property
1248
+ def height(self) -> int | None:
1249
+ """The perscribed height."""
1250
+ # TODO - process min-/max-height
1251
+ if value := self.get("height"):
1252
+ theme_height = css_dimension(
1253
+ value, vertical=True, available=self.available_height
1254
+ )
1255
+ if theme_height is not None:
1256
+ return int(theme_height)
1257
+ return None
1258
+
1259
+ @property
1260
+ def max_height(self) -> int | None:
1261
+ """The maximum permitted height."""
1262
+ if value := self.get("max_height"):
1263
+ theme_height = css_dimension(
1264
+ value, vertical=True, available=self.available_height
1265
+ )
1266
+ if theme_height is not None:
1267
+ return int(theme_height)
1268
+ return None
1269
+
1051
1270
  @property
1052
1271
  def content_height(self) -> int:
1053
1272
  """Return the height available for rendering the element's content."""
1054
1273
  value = self.height
1055
- if value and self.d_blocky:
1056
- value -= self.padding.top + self.padding.bottom
1057
- value -= self.border_visibility.top + self.border_visibility.bottom
1058
- value -= self.margin.top + self.margin.bottom
1059
- return max(value, 0)
1274
+
1275
+ border_box = self.theme.get("box_sizing") == "border-box"
1276
+
1277
+ # Use max-content-height as default for inline-blocks
1278
+ # if value is None and self.d_inline_block and not self.d_image:
1279
+ # value = self.max_content_height
1280
+ # # Do not use border-box for inline-blocks if no height is given
1281
+ # border_box = False
1282
+
1283
+ # If we do not have a value, use the available space
1284
+ if value is None:
1285
+ margin = self.margin
1286
+ value = (self.available_height or 0) - margin.top - margin.bottom
1287
+
1288
+ # Blocks without a set with are rendered with border-box box-sizing
1289
+ # (but not rendered tables as they include padding and borders)
1290
+ if self.d_blocky or self.d_inline_block:
1291
+ border_box = True
1292
+
1293
+ # Ignore box-sizing for table cells, as they include padding and borders
1294
+ if self.d_table_cell:
1295
+ border_box = False
1296
+
1297
+ # Remove padding and borders from available space if we are using box-sizing
1298
+ if border_box:
1299
+ padding = self.padding
1300
+ border_visibility = self.border_visibility
1301
+ value -= padding.top + padding.bottom
1302
+ value -= border_visibility.top + border_visibility.bottom
1303
+
1304
+ # Apply min / max height constraints
1305
+ if (max_height := self.max_height) is not None and max_height < value:
1306
+ value = max_height
1307
+ elif (min_height := self.min_height) is not None and min_height > value:
1308
+ value = min_height
1309
+
1310
+ return max(0, value)
1060
1311
 
1061
1312
  @cached_property
1062
1313
  def padding(self) -> DiInt:
@@ -1144,6 +1395,7 @@ class Theme(Mapping):
1144
1395
  # Replace the margin on the parent
1145
1396
  if (
1146
1397
  (first_child := element.first_child_element)
1398
+ and first_child.prev_node_in_flow is None
1147
1399
  and not self.border_visibility.top
1148
1400
  and not self.padding.top
1149
1401
  ):
@@ -1157,6 +1409,7 @@ class Theme(Mapping):
1157
1409
  values["top"] = max(child_theme.base_margin.top, values["top"])
1158
1410
  if (
1159
1411
  (last_child := element.last_child_element)
1412
+ and last_child.next_node_in_flow is None
1160
1413
  and not self.padding.bottom
1161
1414
  and not self.border_visibility.bottom
1162
1415
  ):
@@ -1183,9 +1436,18 @@ class Theme(Mapping):
1183
1436
  return DiInt(**values)
1184
1437
 
1185
1438
  @cached_property
1186
- def margin_auto(self) -> bool:
1439
+ def block_align(self) -> FormattedTextAlign:
1187
1440
  """Determine if the left and right margins are set to auto."""
1188
- return self.theme["margin_left"] == self.theme["margin_right"] == "auto"
1441
+ # Temporarily use "justify_self" until flex / grid are implemented (TODO)
1442
+ if (self.theme["margin_left"] == self.theme["margin_right"] == "auto") or (
1443
+ self.d_inline_block and self.theme.get("justify_self") == "center"
1444
+ ):
1445
+ return FormattedTextAlign.CENTER
1446
+ elif (self.theme["margin_left"] == "auto") or (
1447
+ self.d_inline_block and self.theme.get("justify_self") == "right"
1448
+ ):
1449
+ return FormattedTextAlign.RIGHT
1450
+ return FormattedTextAlign.LEFT
1189
1451
 
1190
1452
  @cached_property
1191
1453
  def border_style(self) -> DiStr:
@@ -1208,11 +1470,11 @@ class Theme(Mapping):
1208
1470
  elif border_color := get_color(color_str):
1209
1471
  style += f" fg:{border_color}"
1210
1472
 
1211
- if getattr(self.border_line, direction) in {
1212
- UpperRightEighthLine,
1213
- LowerLeftEighthLine,
1214
- }:
1215
- style += f" bg:{self.background_color}"
1473
+ # if getattr(self.border_line, direction) in {
1474
+ # UpperRightEighthLine,
1475
+ # LowerLeftEighthLine,
1476
+ # }:
1477
+ style += f" bg:{self.background_color}"
1216
1478
 
1217
1479
  output[direction] = style
1218
1480
 
@@ -1243,7 +1505,6 @@ class Theme(Mapping):
1243
1505
  def border_line(self) -> DiLineStyle:
1244
1506
  """Calculate the line style."""
1245
1507
  a_w = self.available_width
1246
- self.d_inline
1247
1508
  output = {}
1248
1509
  for direction in ("top", "right", "bottom", "left"):
1249
1510
  border_width = self.theme[f"border_{direction}_width"]
@@ -1294,7 +1555,13 @@ class Theme(Mapping):
1294
1555
  """Get the computed theme foreground color."""
1295
1556
  # TODO - transparency
1296
1557
  color_str = self.theme["color"]
1297
- if color_str == "transparent":
1558
+ # Use black as the default color if a bg color is set
1559
+ if color_str.startswith("var("):
1560
+ var = color_str[4:-1].replace("-", "_")
1561
+ color_str = self.theme.get(var, color_str)
1562
+ if color_str == "default" and self.theme["background_color"] != "default":
1563
+ color_str = "#000000"
1564
+ elif color_str == "transparent":
1298
1565
  return self.background_color
1299
1566
  if fg := get_color(color_str):
1300
1567
  return fg
@@ -1308,9 +1575,16 @@ class Theme(Mapping):
1308
1575
  """Get the computed theme background color."""
1309
1576
  # TODO - transparency
1310
1577
  color_str = self.theme["background_color"]
1578
+ # Use white as the default bg color if a fg color is set
1579
+ # if color_str == "default" and self.theme["color"] != "default":
1580
+ # color_str = "#ffffff"
1581
+ if color_str.startswith("var("):
1582
+ var = color_str[4:-1].replace("-", "_")
1583
+ color_str = self.theme.get(var, color_str)
1311
1584
  if color_str == "transparent":
1312
1585
  if parent_theme := self.parent_theme:
1313
1586
  return parent_theme.background_color
1587
+ color_str = ""
1314
1588
  if bg := get_color(color_str):
1315
1589
  return bg
1316
1590
  elif self.parent_theme:
@@ -1381,6 +1655,19 @@ class Theme(Mapping):
1381
1655
  @cached_property
1382
1656
  def vertical_align(self) -> float:
1383
1657
  """The vertical alignment direction."""
1658
+ if (parent_theme := self.parent_theme) and parent_theme.d_flex:
1659
+ if self.theme.get("align_content") in {
1660
+ "normal",
1661
+ "flex-start",
1662
+ "start",
1663
+ "baseline",
1664
+ }:
1665
+ return 0
1666
+ elif self.theme.get("align_content") == "center":
1667
+ return 0.5
1668
+ else:
1669
+ return 1
1670
+
1384
1671
  return _VERTICAL_ALIGNS.get(self.theme["vertical_align"], 1)
1385
1672
 
1386
1673
  @cached_property
@@ -1418,12 +1705,13 @@ class Theme(Mapping):
1418
1705
  def position(self) -> DiInt:
1419
1706
  """The position of an element with a relative, absolute or fixed position."""
1420
1707
  # TODO - calculate position based on top, left, bottom,right, width, height
1708
+ soup_theme = self.element.dom.soup.theme
1421
1709
  return DiInt(
1422
1710
  top=int(
1423
1711
  css_dimension(
1424
1712
  self.theme["top"],
1425
1713
  vertical=True,
1426
- available=self.element.dom.soup.theme.available_height,
1714
+ available=soup_theme.available_height,
1427
1715
  )
1428
1716
  or 0
1429
1717
  ),
@@ -1431,7 +1719,7 @@ class Theme(Mapping):
1431
1719
  css_dimension(
1432
1720
  self.theme["right"],
1433
1721
  vertical=False,
1434
- available=self.element.dom.soup.theme.available_width,
1722
+ available=soup_theme.available_width,
1435
1723
  )
1436
1724
  or 0
1437
1725
  ),
@@ -1439,7 +1727,7 @@ class Theme(Mapping):
1439
1727
  css_dimension(
1440
1728
  self.theme["bottom"],
1441
1729
  vertical=True,
1442
- available=self.element.dom.soup.theme.available_height,
1730
+ available=soup_theme.available_height,
1443
1731
  )
1444
1732
  or 0
1445
1733
  ),
@@ -1447,12 +1735,22 @@ class Theme(Mapping):
1447
1735
  css_dimension(
1448
1736
  self.theme["left"],
1449
1737
  vertical=False,
1450
- available=self.element.dom.soup.theme.available_width,
1738
+ available=soup_theme.available_width,
1451
1739
  )
1452
1740
  or 0
1453
1741
  ),
1454
1742
  )
1455
1743
 
1744
+ @cached_property
1745
+ def anchors(self) -> DiBool:
1746
+ """Which position directions are set."""
1747
+ return DiBool(
1748
+ top=self.theme["top"] not in {"unset"},
1749
+ right=self.theme["right"] not in {"unset"},
1750
+ bottom=self.theme["bottom"] not in {"unset"},
1751
+ left=self.theme["left"] not in {"unset"},
1752
+ )
1753
+
1456
1754
  @cached_property
1457
1755
  def skip(self) -> bool:
1458
1756
  """Determine if the element should not be displayed."""
@@ -1481,7 +1779,7 @@ class Theme(Mapping):
1481
1779
  """Determine if the element is "in-flow"."""
1482
1780
  return (
1483
1781
  not self.skip
1484
- and self.theme["float"] == "none"
1782
+ and self.floated is None
1485
1783
  and self.theme["position"] not in {"absolute", "fixed"}
1486
1784
  and self.element.name != "html"
1487
1785
  )
@@ -1501,418 +1799,498 @@ class Theme(Mapping):
1501
1799
  return self.theme.__len__()
1502
1800
 
1503
1801
 
1504
- _BROWSER_CSS = {
1505
- # Non-rendered elements
1506
- (
1507
- (CssSelector(item="head"),),
1508
- (CssSelector(item="base"),),
1509
- (CssSelector(item="command"),),
1510
- (CssSelector(item="link"),),
1511
- (CssSelector(item="meta"),),
1512
- (CssSelector(item="noscript"),),
1513
- (CssSelector(item="script"),),
1514
- (CssSelector(item="style"),),
1515
- (CssSelector(item="title"),),
1516
- (CssSelector(item="option"),),
1517
- (CssSelector(item="input", attr="[type=hidden]"),),
1518
- ): {"display": "none"},
1519
- # Inline elements
1520
- (
1521
- (CssSelector(item="::before"),),
1522
- (CssSelector(item="::after"),),
1523
- (CssSelector(item="text"),),
1524
- (CssSelector(item="abbr"),),
1525
- (CssSelector(item="acronym"),),
1526
- (CssSelector(item="audio"),),
1527
- (CssSelector(item="bdi"),),
1528
- (CssSelector(item="bdo"),),
1529
- (CssSelector(item="big"),),
1530
- (CssSelector(item="br"),),
1531
- (CssSelector(item="canvas"),),
1532
- (CssSelector(item="data"),),
1533
- (CssSelector(item="datalist"),),
1534
- (CssSelector(item="embed"),),
1535
- (CssSelector(item="iframe"),),
1536
- (CssSelector(item="label"),),
1537
- (CssSelector(item="map"),),
1538
- (CssSelector(item="meter"),),
1539
- (CssSelector(item="object"),),
1540
- (CssSelector(item="output"),),
1541
- (CssSelector(item="picture"),),
1542
- (CssSelector(item="progress"),),
1543
- (CssSelector(item="q"),),
1544
- (CssSelector(item="ruby"),),
1545
- (CssSelector(item="select"),),
1546
- (CssSelector(item="slot"),),
1547
- (CssSelector(item="small"),),
1548
- (CssSelector(item="span"),),
1549
- (CssSelector(item="template"),),
1550
- (CssSelector(item="textarea"),),
1551
- (CssSelector(item="time"),),
1552
- (CssSelector(item="tt"),),
1553
- (CssSelector(item="video"),),
1554
- (CssSelector(item="wbr"),),
1555
- ): {"display": "inline"},
1556
- # Formatted inlines
1557
- ((CssSelector(item="a"),),): {
1558
- "display": "inline",
1559
- "text_decoration": "underline",
1560
- "color": "ansibrightblue",
1561
- },
1562
- ((CssSelector(item="b"),), (CssSelector(item="strong"),)): {
1563
- "display": "inline",
1564
- "font_weight": "bold",
1565
- },
1566
- (
1567
- (CssSelector(item="cite"),),
1568
- (CssSelector(item="dfn"),),
1569
- (CssSelector(item="em"),),
1570
- (CssSelector(item="i"),),
1571
- (CssSelector(item="var"),),
1572
- ): {"display": "inline", "font_style": "italic"},
1573
- ((CssSelector(item="code"),),): {
1574
- "display": "inline",
1575
- },
1576
- ((CssSelector(item="del"),), (CssSelector(item="s"),)): {
1577
- "display": "inline",
1578
- "text_decoration": "line-through",
1579
- },
1580
- ((CssSelector(item="img"),), (CssSelector(item="svg"),)): {
1581
- "display": "inline-block",
1582
- "overflow_x": "hidden",
1583
- "overflow_y": "hidden",
1584
- },
1585
- ((CssSelector(item="ins"),), (CssSelector(item="u"),)): {
1586
- "display": "inline",
1587
- "text_decoration": "underline",
1588
- },
1589
- ((CssSelector(item="kbd"),),): {
1590
- "display": "inline",
1591
- "background_color": "#333344",
1592
- "color": "#FFFFFF",
1593
- },
1594
- ((CssSelector(item="mark"),),): {
1595
- "display": "inline",
1596
- "color": "black",
1597
- "background_color": "#FFFF00",
1598
- },
1599
- ((CssSelector(item="samp"),),): {
1600
- "display": "inline",
1601
- "background_color": "#334433",
1602
- "color": "#FFFFFF",
1603
- },
1604
- ((CssSelector(item="sub"),),): {"display": "inline", "vertical_align": "sub"},
1605
- ((CssSelector(item="sup"),),): {"display": "inline", "vertical_align": "super"},
1606
- (
1802
+ _BROWSER_CSS: CssSelectors = {
1803
+ always: {
1804
+ # Set body background to white
1805
+ ((CssSelector(item="body"),),): {"background_color": "#FFFFFF"},
1806
+ # Non-rendered elements
1607
1807
  (
1608
- CssSelector(item="q"),
1609
- CssSelector(item="::before"),
1610
- ),
1611
- ): {"content": "“"},
1612
- (
1808
+ (CssSelector(item="head"),),
1809
+ (CssSelector(item="base"),),
1810
+ (CssSelector(item="command"),),
1811
+ (CssSelector(item="link"),),
1812
+ (CssSelector(item="meta"),),
1813
+ (CssSelector(item="noscript"),),
1814
+ (CssSelector(item="script"),),
1815
+ (CssSelector(item="style"),),
1816
+ (CssSelector(item="title"),),
1817
+ (CssSelector(item="option"),),
1818
+ (CssSelector(item="input", attr="[type=hidden]"),),
1819
+ ): {"display": "none"},
1820
+ # Inline elements
1613
1821
  (
1614
- CssSelector(item="q"),
1615
- CssSelector(item="::after"),
1616
- ),
1617
- ): {"content": "”"},
1618
- # Images
1619
- (
1620
- (CssSelector(item="img", attr="[_missing]"),),
1621
- (CssSelector(item="svg", attr="[_missing]"),),
1622
- ): {
1623
- "border_top_style": "solid",
1624
- "border_right_style": "solid",
1625
- "border_bottom_style": "solid",
1626
- "border_left_style": "solid",
1627
- "border_top_width": "1px",
1628
- "border_right_width": "1px",
1629
- "border_bottom_width": "1px",
1630
- "border_left_width": "1px",
1631
- },
1632
- # Alignment
1633
- ((CssSelector(item="center"),), (CssSelector(item="caption"),)): {
1634
- "text_align": "center",
1635
- "display": "block",
1636
- },
1637
- # Tables
1638
- ((CssSelector(item="table"),),): {
1639
- "display": "table",
1640
- "border_collapse": "collapse",
1641
- },
1642
- ((CssSelector(item="td"),),): {"display": "table-cell", "text_align": "unset"},
1643
- ((CssSelector(item="th"),),): {
1644
- "display": "table-cell",
1645
- "font_weight": "bold",
1646
- "text_align": "center",
1647
- },
1648
- # Forms
1649
- ((CssSelector(item="input"),),): {
1650
- "display": "inline-block",
1651
- "white_space": "pre",
1652
- "color": "#000000",
1653
- "border_top_style": "inset",
1654
- "border_right_style": "inset",
1655
- "border_bottom_style": "inset",
1656
- "border_left_style": "inset",
1657
- "border_top_width": "2px",
1658
- "border_right_width": "2px",
1659
- "border_bottom_width": "2px",
1660
- "border_left_width": "2px",
1661
- "vertical_align": "middle",
1662
- },
1663
- ((CssSelector(item="input", attr="[type=text]"),),): {
1664
- "background_color": "#FFFFFF",
1665
- "border_top_color": "#606060",
1666
- "border_right_color": "#E9E7E3",
1667
- "border_bottom_color": "#E9E7E3",
1668
- "border_left_color": "#606060",
1669
- "overflow_x": "hidden",
1670
- },
1671
- (
1672
- (CssSelector(item="input", attr="[type=button]"),),
1673
- (CssSelector(item="input", attr="[type=submit]"),),
1674
- (CssSelector(item="input", attr="[type=reset]"),),
1675
- ): {
1676
- "background_color": "#d4d0c8",
1677
- "border_right": "#606060",
1678
- "border_bottom": "#606060",
1679
- "border_left": "#ffffff",
1680
- "border_top": "#ffffff",
1681
- },
1682
- ((CssSelector(item="button"),),): {
1683
- "display": "inline-block",
1684
- "color": "#000000",
1685
- "border_top_style": "outset",
1686
- "border_right_style": "outset",
1687
- "border_bottom_style": "outset",
1688
- "border_left_style": "outset",
1689
- "border_top_width": "2px",
1690
- "border_right_width": "2px",
1691
- "border_bottom_width": "2px",
1692
- "border_left_width": "2px",
1693
- "background_color": "#d4d0c8",
1694
- "border_right": "#606060",
1695
- "border_bottom": "#606060",
1696
- "border_left": "#ffffff",
1697
- "border_top": "#ffffff",
1698
- },
1699
- # Headings
1700
- ((CssSelector(item="h1"),),): {
1701
- "font_weight": "bold",
1702
- "text_decoration": "underline",
1703
- "border_bottom_style": "solid",
1704
- "border_bottom_width": "thick",
1705
- "padding_bottom": "2rem",
1706
- "margin_top": "2rem",
1707
- "margin_bottom": "2em",
1708
- },
1709
- ((CssSelector(item="h2"),),): {
1710
- "font_weight": "bold",
1711
- "border_bottom_style": "double",
1712
- "border_bottom_width": "thick",
1713
- "padding_bottom": "1.5rem",
1714
- "margin_top": "1.5rem",
1715
- "margin_bottom": "1.5rem",
1716
- },
1717
- ((CssSelector(item="h3"),),): {
1718
- "font_weight": "bold",
1719
- "font_style": "italic",
1720
- "border_bottom_style": ":lower-left",
1721
- "border_bottom_width": "thin",
1722
- "padding_top": "1rem",
1723
- "padding_bottom": "1rem",
1724
- "margin_bottom": "1.5rem",
1725
- },
1726
- ((CssSelector(item="h4"),),): {
1727
- "text_decoration": "underline",
1728
- "border_bottom_style": "solid",
1729
- "border_bottom_width": "thin",
1730
- "padding_top": "1rem",
1731
- "padding_bottom": "1rem",
1732
- "margin_bottom": "1.5rem",
1733
- },
1734
- ((CssSelector(item="h5"),),): {
1735
- "border_bottom_style": "dashed",
1736
- "border_bottom_width": "thin",
1737
- "margin_bottom": "1.5rem",
1738
- },
1739
- ((CssSelector(item="h6"),),): {
1740
- "font_style": "italic",
1741
- "border_bottom_style": "dotted",
1742
- "border_bottom_width": "thin",
1743
- "margin_bottom": "1.5rem",
1744
- },
1745
- # Misc blocks
1746
- ((CssSelector(item="blockquote"),),): {
1747
- "margin_top": "1em",
1748
- "margin_bottom": "1em",
1749
- "margin_right": "2em",
1750
- "margin_left": "2em",
1751
- },
1752
- ((CssSelector(item="hr"),),): {
1753
- "margin_top": "1rem",
1754
- "margin_bottom": "1rem",
1755
- "border_top_width": "thin",
1756
- "border_top_style": "solid",
1757
- "border_top_color": "ansired",
1758
- },
1759
- ((CssSelector(item="p"),),): {"margin_top": "1em", "margin_bottom": "1em"},
1760
- ((CssSelector(item="pre"),),): {
1761
- "margin_top": "1em",
1762
- "margin_bottom": "1em",
1763
- "white_space": "pre",
1764
- },
1765
- # Lists
1766
- ((CssSelector(item="::marker"),),): {
1767
- "display": "inline",
1768
- "padding_right": "1em",
1769
- "text_align": "right",
1770
- },
1771
- ((CssSelector(item="ol"),),): {
1772
- "list_style_type": "decimal",
1773
- "list_style_position": "outside",
1774
- "padding_left": "4em",
1775
- "margin_top": "1em",
1776
- "margin_bottom": "1em",
1777
- },
1778
- (
1779
- (CssSelector(item="ul"),),
1780
- (CssSelector(item="menu"),),
1781
- (CssSelector(item="dir"),),
1782
- ): {
1783
- "list_style_type": "disc",
1784
- "list_style_position": "outside",
1785
- "padding_left": "3em",
1786
- "margin_top": "1em",
1787
- "margin_bottom": "1em",
1788
- },
1789
- (
1790
- (CssSelector(item="dir"), CssSelector(item="dir")),
1791
- (CssSelector(item="dir"), CssSelector(item="menu")),
1792
- (CssSelector(item="dir"), CssSelector(item="ul")),
1793
- (CssSelector(item="ol"), CssSelector(item="dir")),
1794
- (CssSelector(item="ol"), CssSelector(item="menu")),
1795
- (CssSelector(item="ol"), CssSelector(item="ul")),
1796
- (CssSelector(item="menu"), CssSelector(item="dir")),
1797
- (CssSelector(item="menu"), CssSelector(item="menu")),
1798
- (CssSelector(item="ul"), CssSelector(item="dir")),
1799
- (CssSelector(item="ul"), CssSelector(item="menu")),
1800
- (CssSelector(item="ul"), CssSelector(item="ul")),
1801
- ): {"margin_top": "0em", "margin_bottom": "0em", "list_style_type": "circle"},
1802
- (
1803
- (CssSelector(item="dir"), CssSelector(item="dl")),
1804
- (CssSelector(item="dir"), CssSelector(item="ol")),
1805
- (CssSelector(item="dl"), CssSelector(item="dir")),
1806
- (CssSelector(item="dl"), CssSelector(item="dl")),
1807
- (CssSelector(item="dl"), CssSelector(item="ol")),
1808
- (CssSelector(item="dl"), CssSelector(item="menu")),
1809
- (CssSelector(item="dl"), CssSelector(item="ul")),
1810
- (CssSelector(item="ol"), CssSelector(item="dl")),
1811
- (CssSelector(item="ol"), CssSelector(item="ol")),
1812
- (CssSelector(item="menu"), CssSelector(item="dl")),
1813
- (CssSelector(item="menu"), CssSelector(item="ol")),
1814
- (CssSelector(item="ul"), CssSelector(item="dl")),
1815
- (CssSelector(item="ul"), CssSelector(item="ol")),
1816
- ): {"margin_top": "0em", "margin_bottom": "0em"},
1817
- ((CssSelector(item="menu"), CssSelector(item="ul")),): {
1818
- "list_style_type": "circle",
1819
- "margin_top": "0em",
1820
- "margin_bottom": "0em",
1821
- },
1822
- (
1823
- (CssSelector(item="dir"), CssSelector(item="dir"), CssSelector(item="dir")),
1824
- (CssSelector(item="dir"), CssSelector(item="dir"), CssSelector(item="menu")),
1825
- (CssSelector(item="dir"), CssSelector(item="dir"), CssSelector(item="ul")),
1826
- (CssSelector(item="dir"), CssSelector(item="menu"), CssSelector(item="dir")),
1827
- (CssSelector(item="dir"), CssSelector(item="menu"), CssSelector(item="menu")),
1828
- (CssSelector(item="dir"), CssSelector(item="menu"), CssSelector(item="ul")),
1829
- (CssSelector(item="dir"), CssSelector(item="ol"), CssSelector(item="dir")),
1830
- (CssSelector(item="dir"), CssSelector(item="ol"), CssSelector(item="menu")),
1831
- (CssSelector(item="dir"), CssSelector(item="ol"), CssSelector(item="ul")),
1832
- (CssSelector(item="dir"), CssSelector(item="ul"), CssSelector(item="dir")),
1833
- (CssSelector(item="dir"), CssSelector(item="ul"), CssSelector(item="menu")),
1834
- (CssSelector(item="dir"), CssSelector(item="ul"), CssSelector(item="ul")),
1835
- (CssSelector(item="menu"), CssSelector(item="dir"), CssSelector(item="dir")),
1836
- (CssSelector(item="menu"), CssSelector(item="dir"), CssSelector(item="menu")),
1837
- (CssSelector(item="menu"), CssSelector(item="dir"), CssSelector(item="ul")),
1838
- (CssSelector(item="menu"), CssSelector(item="menu"), CssSelector(item="dir")),
1839
- (CssSelector(item="menu"), CssSelector(item="menu"), CssSelector(item="menu")),
1840
- (CssSelector(item="menu"), CssSelector(item="menu"), CssSelector(item="ul")),
1841
- (CssSelector(item="menu"), CssSelector(item="ol"), CssSelector(item="dir")),
1842
- (CssSelector(item="menu"), CssSelector(item="ol"), CssSelector(item="menu")),
1843
- (CssSelector(item="menu"), CssSelector(item="ol"), CssSelector(item="ul")),
1844
- (CssSelector(item="menu"), CssSelector(item="ul"), CssSelector(item="dir")),
1845
- (CssSelector(item="menu"), CssSelector(item="ul"), CssSelector(item="menu")),
1846
- (CssSelector(item="menu"), CssSelector(item="ul"), CssSelector(item="ul")),
1847
- (CssSelector(item="ol"), CssSelector(item="dir"), CssSelector(item="dir")),
1848
- (CssSelector(item="ol"), CssSelector(item="dir"), CssSelector(item="menu")),
1849
- (CssSelector(item="ol"), CssSelector(item="dir"), CssSelector(item="ul")),
1850
- (CssSelector(item="ol"), CssSelector(item="menu"), CssSelector(item="dir")),
1851
- (CssSelector(item="ol"), CssSelector(item="menu"), CssSelector(item="menu")),
1852
- (CssSelector(item="ol"), CssSelector(item="menu"), CssSelector(item="ul")),
1853
- (CssSelector(item="ol"), CssSelector(item="ol"), CssSelector(item="dir")),
1854
- (CssSelector(item="ol"), CssSelector(item="ol"), CssSelector(item="menu")),
1855
- (CssSelector(item="ol"), CssSelector(item="ol"), CssSelector(item="ul")),
1856
- (CssSelector(item="ol"), CssSelector(item="ul"), CssSelector(item="dir")),
1857
- (CssSelector(item="ol"), CssSelector(item="ul"), CssSelector(item="menu")),
1858
- (CssSelector(item="ol"), CssSelector(item="ul"), CssSelector(item="ul")),
1859
- (CssSelector(item="ul"), CssSelector(item="dir"), CssSelector(item="dir")),
1860
- (CssSelector(item="ul"), CssSelector(item="dir"), CssSelector(item="menu")),
1861
- (CssSelector(item="ul"), CssSelector(item="dir"), CssSelector(item="ul")),
1862
- (CssSelector(item="ul"), CssSelector(item="menu"), CssSelector(item="dir")),
1863
- (CssSelector(item="ul"), CssSelector(item="menu"), CssSelector(item="menu")),
1864
- (CssSelector(item="ul"), CssSelector(item="menu"), CssSelector(item="ul")),
1865
- (CssSelector(item="ul"), CssSelector(item="ol"), CssSelector(item="dir")),
1866
- (CssSelector(item="ul"), CssSelector(item="ol"), CssSelector(item="menu")),
1867
- (CssSelector(item="ul"), CssSelector(item="ol"), CssSelector(item="ul")),
1868
- (CssSelector(item="ul"), CssSelector(item="ul"), CssSelector(item="dir")),
1869
- (CssSelector(item="ul"), CssSelector(item="ul"), CssSelector(item="menu")),
1870
- (CssSelector(item="ul"), CssSelector(item="ul"), CssSelector(item="ul")),
1871
- ): {"list_style_type": "square"},
1872
- ((CssSelector(item="li"),),): {"display": "list-item"},
1873
- ((CssSelector(item="details"),),): {
1874
- "list_style_type": "disclosure-closed",
1875
- "list_style_position": "inside",
1876
- },
1877
- ((CssSelector(item="details", attr="[open]"), CssSelector(item="summary")),): {
1878
- "list_style_type": "disclosure-open"
1879
- },
1880
- ((CssSelector(item="summary"),),): {"display": "list-item", "font_weight": "bold"},
1881
- # Dataframes for Jupyter
1882
- ((CssSelector(item=".dataframe"),),): {"_pt_class": "dataframe"},
1883
- (
1884
- (CssSelector(item=".dataframe"), CssSelector(item="td")),
1885
- (CssSelector(item=".dataframe"), CssSelector(item="th")),
1886
- ): {
1887
- "border_top_style": "hidden",
1888
- "border_left_style": "hidden",
1889
- "border_bottom_style": "hidden",
1890
- "border_right_style": "hidden",
1891
- "padding_left": "1em",
1892
- },
1893
- (
1822
+ (CssSelector(item="::before"),),
1823
+ (CssSelector(item="::after"),),
1824
+ (CssSelector(item="text"),),
1825
+ (CssSelector(item="abbr"),),
1826
+ (CssSelector(item="acronym"),),
1827
+ (CssSelector(item="audio"),),
1828
+ (CssSelector(item="bdi"),),
1829
+ (CssSelector(item="bdo"),),
1830
+ (CssSelector(item="big"),),
1831
+ (CssSelector(item="br"),),
1832
+ (CssSelector(item="canvas"),),
1833
+ (CssSelector(item="data"),),
1834
+ (CssSelector(item="datalist"),),
1835
+ (CssSelector(item="embed"),),
1836
+ (CssSelector(item="iframe"),),
1837
+ (CssSelector(item="label"),),
1838
+ (CssSelector(item="map"),),
1839
+ (CssSelector(item="meter"),),
1840
+ (CssSelector(item="object"),),
1841
+ (CssSelector(item="output"),),
1842
+ (CssSelector(item="picture"),),
1843
+ (CssSelector(item="progress"),),
1844
+ (CssSelector(item="q"),),
1845
+ (CssSelector(item="ruby"),),
1846
+ (CssSelector(item="select"),),
1847
+ (CssSelector(item="slot"),),
1848
+ (CssSelector(item="small"),),
1849
+ (CssSelector(item="span"),),
1850
+ (CssSelector(item="template"),),
1851
+ (CssSelector(item="textarea"),),
1852
+ (CssSelector(item="time"),),
1853
+ (CssSelector(item="tt"),),
1854
+ (CssSelector(item="video"),),
1855
+ (CssSelector(item="wbr"),),
1856
+ ): {"display": "inline"},
1857
+ # Formatted inlines
1858
+ ((CssSelector(item="a"),),): {
1859
+ "display": "inline",
1860
+ "text_decoration": "underline",
1861
+ "color": "ansibrightblue",
1862
+ },
1863
+ ((CssSelector(item="b"),), (CssSelector(item="strong"),)): {
1864
+ "display": "inline",
1865
+ "font_weight": "bold",
1866
+ },
1894
1867
  (
1895
- CssSelector(item=".dataframe"),
1896
- CssSelector(item="th"),
1897
- ),
1898
- ): {
1899
- "_pt_class": "dataframe,th",
1900
- },
1901
- (
1902
- (CssSelector(item=".dataframe"), CssSelector(item="th", pseudo=":first-child")),
1903
- (CssSelector(item=".dataframe"), CssSelector(item="th", pseudo=":last-child")),
1904
- (CssSelector(item=".dataframe"), CssSelector(item="td", pseudo=":last-child")),
1905
- ): {"padding_right": "1em"},
1906
- ((CssSelector(item=".dataframe"), CssSelector(item="td")),): {
1907
- "_pt_class": "dataframe,td bg:default"
1908
- },
1909
- (
1868
+ (CssSelector(item="cite"),),
1869
+ (CssSelector(item="dfn"),),
1870
+ (CssSelector(item="em"),),
1871
+ (CssSelector(item="i"),),
1872
+ (CssSelector(item="var"),),
1873
+ ): {"display": "inline", "font_style": "italic"},
1874
+ ((CssSelector(item="code"),),): {
1875
+ "display": "inline",
1876
+ },
1877
+ ((CssSelector(item="del"),), (CssSelector(item="s"),)): {
1878
+ "display": "inline",
1879
+ "text_decoration": "line-through",
1880
+ },
1881
+ (
1882
+ (CssSelector(item="img", attr="[width=0]"),),
1883
+ (CssSelector(item="img", attr="[height=0]"),),
1884
+ (CssSelector(item="svg", attr="[width=0]"),),
1885
+ (CssSelector(item="svg", attr="[height=0]"),),
1886
+ ): {"display": "none"},
1887
+ ((CssSelector(item="img"),), (CssSelector(item="svg"),)): {
1888
+ "display": "inline-block",
1889
+ "overflow_x": "hidden",
1890
+ "overflow_y": "hidden",
1891
+ },
1892
+ ((CssSelector(item="ins"),), (CssSelector(item="u"),)): {
1893
+ "display": "inline",
1894
+ "text_decoration": "underline",
1895
+ },
1896
+ ((CssSelector(item="kbd"),),): {
1897
+ "display": "inline",
1898
+ "background_color": "#333344",
1899
+ "color": "#FFFFFF",
1900
+ },
1901
+ ((CssSelector(item="mark"),),): {
1902
+ "display": "inline",
1903
+ "color": "black",
1904
+ "background_color": "#FFFF00",
1905
+ },
1906
+ ((CssSelector(item="samp"),),): {
1907
+ "display": "inline",
1908
+ "background_color": "#334433",
1909
+ "color": "#FFFFFF",
1910
+ },
1911
+ ((CssSelector(item="sub"),),): {"display": "inline", "vertical_align": "sub"},
1912
+ ((CssSelector(item="sup"),),): {"display": "inline", "vertical_align": "super"},
1913
+ (
1914
+ (
1915
+ CssSelector(item="q"),
1916
+ CssSelector(item="::before"),
1917
+ ),
1918
+ ): {"content": "“"},
1910
1919
  (
1911
- CssSelector(item=".dataframe"),
1912
- CssSelector(item="tr", pseudo=":nth-child(odd)"),
1913
- CssSelector(item="td"),
1914
- ),
1915
- ): {"_pt_class": "dataframe,row-odd,td"},
1920
+ (
1921
+ CssSelector(item="q"),
1922
+ CssSelector(item="::after"),
1923
+ ),
1924
+ ): {"content": ""},
1925
+ # Images
1926
+ (
1927
+ (CssSelector(item="img", attr="[_missing]"),),
1928
+ (CssSelector(item="svg", attr="[_missing]"),),
1929
+ ): {
1930
+ "border_top_style": "solid",
1931
+ "border_right_style": "solid",
1932
+ "border_bottom_style": "solid",
1933
+ "border_left_style": "solid",
1934
+ "border_top_width": "1px",
1935
+ "border_right_width": "1px",
1936
+ "border_bottom_width": "1px",
1937
+ "border_left_width": "1px",
1938
+ },
1939
+ # Alignment
1940
+ ((CssSelector(item="center"),), (CssSelector(item="caption"),)): {
1941
+ "text_align": "center",
1942
+ "display": "block",
1943
+ },
1944
+ # Tables
1945
+ ((CssSelector(item="table"),),): {
1946
+ "display": "table",
1947
+ "border_collapse": "collapse",
1948
+ },
1949
+ (
1950
+ (CssSelector(item="td"),),
1951
+ (CssSelector(item="th"),),
1952
+ ): {
1953
+ "border_top_width": "0px",
1954
+ "border_right_width": "0px",
1955
+ "border_bottom_width": "0px",
1956
+ "border_left_width": "0px",
1957
+ },
1958
+ ((CssSelector(item="td"),),): {"display": "table-cell", "text_align": "unset"},
1959
+ ((CssSelector(item="th"),),): {
1960
+ "display": "table-cell",
1961
+ "font_weight": "bold",
1962
+ "text_align": "center",
1963
+ },
1964
+ # Forms
1965
+ ((CssSelector(item="input"),),): {
1966
+ "display": "inline-block",
1967
+ "white_space": "pre",
1968
+ "color": "#000000",
1969
+ "border_top_style": "inset",
1970
+ "border_right_style": "inset",
1971
+ "border_bottom_style": "inset",
1972
+ "border_left_style": "inset",
1973
+ "border_top_width": "2px",
1974
+ "border_right_width": "2px",
1975
+ "border_bottom_width": "2px",
1976
+ "border_left_width": "2px",
1977
+ "vertical_align": "middle",
1978
+ },
1979
+ ((CssSelector(item="input", attr="[type=text]"),),): {
1980
+ "background_color": "#FAFAFA",
1981
+ "border_top_color": "#606060",
1982
+ "border_right_color": "#E9E7E3",
1983
+ "border_bottom_color": "#E9E7E3",
1984
+ "border_left_color": "#606060",
1985
+ "overflow_x": "hidden",
1986
+ },
1987
+ (
1988
+ (CssSelector(item="input", attr="[type=button]"),),
1989
+ (CssSelector(item="input", attr="[type=submit]"),),
1990
+ (CssSelector(item="input", attr="[type=reset]"),),
1991
+ ): {
1992
+ "background_color": "#d4d0c8",
1993
+ "border_right": "#606060",
1994
+ "border_bottom": "#606060",
1995
+ "border_left": "#ffffff",
1996
+ "border_top": "#ffffff",
1997
+ },
1998
+ ((CssSelector(item="button"),),): {
1999
+ "display": "inline-block",
2000
+ "color": "#000000",
2001
+ "border_top_style": "outset",
2002
+ "border_right_style": "outset",
2003
+ "border_bottom_style": "outset",
2004
+ "border_left_style": "outset",
2005
+ "border_top_width": "2px",
2006
+ "border_right_width": "2px",
2007
+ "border_bottom_width": "2px",
2008
+ "border_left_width": "2px",
2009
+ "background_color": "#d4d0c8",
2010
+ "border_right": "#606060",
2011
+ "border_bottom": "#606060",
2012
+ "border_left": "#ffffff",
2013
+ "border_top": "#ffffff",
2014
+ },
2015
+ # Headings
2016
+ ((CssSelector(item="h1"),),): {
2017
+ "font_weight": "bold",
2018
+ "text_decoration": "underline",
2019
+ "border_bottom_style": "solid",
2020
+ "border_bottom_width": "thick",
2021
+ "padding_bottom": "2rem",
2022
+ "margin_top": "2rem",
2023
+ "margin_bottom": "2em",
2024
+ },
2025
+ ((CssSelector(item="h2"),),): {
2026
+ "font_weight": "bold",
2027
+ "border_bottom_style": "double",
2028
+ "border_bottom_width": "thick",
2029
+ "padding_bottom": "1.5rem",
2030
+ "margin_top": "1.5rem",
2031
+ "margin_bottom": "1.5rem",
2032
+ },
2033
+ ((CssSelector(item="h3"),),): {
2034
+ "font_weight": "bold",
2035
+ "font_style": "italic",
2036
+ "border_bottom_style": ":lower-left",
2037
+ "border_bottom_width": "thin",
2038
+ "padding_top": "1rem",
2039
+ "padding_bottom": "1rem",
2040
+ "margin_bottom": "1.5rem",
2041
+ },
2042
+ ((CssSelector(item="h4"),),): {
2043
+ "text_decoration": "underline",
2044
+ "border_bottom_style": "solid",
2045
+ "border_bottom_width": "thin",
2046
+ "padding_top": "1rem",
2047
+ "padding_bottom": "1rem",
2048
+ "margin_bottom": "1.5rem",
2049
+ },
2050
+ ((CssSelector(item="h5"),),): {
2051
+ "border_bottom_style": "dashed",
2052
+ "border_bottom_width": "thin",
2053
+ "margin_bottom": "1.5rem",
2054
+ },
2055
+ ((CssSelector(item="h6"),),): {
2056
+ "font_style": "italic",
2057
+ "border_bottom_style": "dotted",
2058
+ "border_bottom_width": "thin",
2059
+ "margin_bottom": "1.5rem",
2060
+ },
2061
+ # Misc blocks
2062
+ ((CssSelector(item="blockquote"),),): {
2063
+ "margin_top": "1em",
2064
+ "margin_bottom": "1em",
2065
+ "margin_right": "2em",
2066
+ "margin_left": "2em",
2067
+ },
2068
+ ((CssSelector(item="hr"),),): {
2069
+ "margin_top": "1rem",
2070
+ "margin_bottom": "1rem",
2071
+ "border_top_width": "thin",
2072
+ "border_top_style": "solid",
2073
+ "border_top_color": "ansired",
2074
+ "width": "100%",
2075
+ },
2076
+ ((CssSelector(item="p"),),): {"margin_top": "1em", "margin_bottom": "1em"},
2077
+ ((CssSelector(item="pre"),),): {
2078
+ "margin_top": "1em",
2079
+ "margin_bottom": "1em",
2080
+ "white_space": "pre",
2081
+ },
2082
+ # Lists
2083
+ ((CssSelector(item="::marker"),),): {
2084
+ "display": "inline",
2085
+ "padding_right": "1em",
2086
+ "text_align": "right",
2087
+ },
2088
+ ((CssSelector(item="ol"),),): {
2089
+ "list_style_type": "decimal",
2090
+ "list_style_position": "outside",
2091
+ "padding_left": "4em",
2092
+ "margin_top": "1em",
2093
+ "margin_bottom": "1em",
2094
+ },
2095
+ (
2096
+ (CssSelector(item="ul"),),
2097
+ (CssSelector(item="menu"),),
2098
+ (CssSelector(item="dir"),),
2099
+ ): {
2100
+ "list_style_type": "disc",
2101
+ "list_style_position": "outside",
2102
+ "padding_left": "3em",
2103
+ "margin_top": "1em",
2104
+ "margin_bottom": "1em",
2105
+ },
2106
+ (
2107
+ (CssSelector(item="dir"), CssSelector(item="dir")),
2108
+ (CssSelector(item="dir"), CssSelector(item="menu")),
2109
+ (CssSelector(item="dir"), CssSelector(item="ul")),
2110
+ (CssSelector(item="ol"), CssSelector(item="dir")),
2111
+ (CssSelector(item="ol"), CssSelector(item="menu")),
2112
+ (CssSelector(item="ol"), CssSelector(item="ul")),
2113
+ (CssSelector(item="menu"), CssSelector(item="dir")),
2114
+ (CssSelector(item="menu"), CssSelector(item="menu")),
2115
+ (CssSelector(item="ul"), CssSelector(item="dir")),
2116
+ (CssSelector(item="ul"), CssSelector(item="menu")),
2117
+ (CssSelector(item="ul"), CssSelector(item="ul")),
2118
+ ): {"margin_top": "0em", "margin_bottom": "0em", "list_style_type": "circle"},
2119
+ (
2120
+ (CssSelector(item="dir"), CssSelector(item="dl")),
2121
+ (CssSelector(item="dir"), CssSelector(item="ol")),
2122
+ (CssSelector(item="dl"), CssSelector(item="dir")),
2123
+ (CssSelector(item="dl"), CssSelector(item="dl")),
2124
+ (CssSelector(item="dl"), CssSelector(item="ol")),
2125
+ (CssSelector(item="dl"), CssSelector(item="menu")),
2126
+ (CssSelector(item="dl"), CssSelector(item="ul")),
2127
+ (CssSelector(item="ol"), CssSelector(item="dl")),
2128
+ (CssSelector(item="ol"), CssSelector(item="ol")),
2129
+ (CssSelector(item="menu"), CssSelector(item="dl")),
2130
+ (CssSelector(item="menu"), CssSelector(item="ol")),
2131
+ (CssSelector(item="ul"), CssSelector(item="dl")),
2132
+ (CssSelector(item="ul"), CssSelector(item="ol")),
2133
+ ): {"margin_top": "0em", "margin_bottom": "0em"},
2134
+ ((CssSelector(item="menu"), CssSelector(item="ul")),): {
2135
+ "list_style_type": "circle",
2136
+ "margin_top": "0em",
2137
+ "margin_bottom": "0em",
2138
+ },
2139
+ (
2140
+ (CssSelector(item="dir"), CssSelector(item="dir"), CssSelector(item="dir")),
2141
+ (
2142
+ CssSelector(item="dir"),
2143
+ CssSelector(item="dir"),
2144
+ CssSelector(item="menu"),
2145
+ ),
2146
+ (CssSelector(item="dir"), CssSelector(item="dir"), CssSelector(item="ul")),
2147
+ (
2148
+ CssSelector(item="dir"),
2149
+ CssSelector(item="menu"),
2150
+ CssSelector(item="dir"),
2151
+ ),
2152
+ (
2153
+ CssSelector(item="dir"),
2154
+ CssSelector(item="menu"),
2155
+ CssSelector(item="menu"),
2156
+ ),
2157
+ (CssSelector(item="dir"), CssSelector(item="menu"), CssSelector(item="ul")),
2158
+ (CssSelector(item="dir"), CssSelector(item="ol"), CssSelector(item="dir")),
2159
+ (CssSelector(item="dir"), CssSelector(item="ol"), CssSelector(item="menu")),
2160
+ (CssSelector(item="dir"), CssSelector(item="ol"), CssSelector(item="ul")),
2161
+ (CssSelector(item="dir"), CssSelector(item="ul"), CssSelector(item="dir")),
2162
+ (CssSelector(item="dir"), CssSelector(item="ul"), CssSelector(item="menu")),
2163
+ (CssSelector(item="dir"), CssSelector(item="ul"), CssSelector(item="ul")),
2164
+ (
2165
+ CssSelector(item="menu"),
2166
+ CssSelector(item="dir"),
2167
+ CssSelector(item="dir"),
2168
+ ),
2169
+ (
2170
+ CssSelector(item="menu"),
2171
+ CssSelector(item="dir"),
2172
+ CssSelector(item="menu"),
2173
+ ),
2174
+ (CssSelector(item="menu"), CssSelector(item="dir"), CssSelector(item="ul")),
2175
+ (
2176
+ CssSelector(item="menu"),
2177
+ CssSelector(item="menu"),
2178
+ CssSelector(item="dir"),
2179
+ ),
2180
+ (
2181
+ CssSelector(item="menu"),
2182
+ CssSelector(item="menu"),
2183
+ CssSelector(item="menu"),
2184
+ ),
2185
+ (
2186
+ CssSelector(item="menu"),
2187
+ CssSelector(item="menu"),
2188
+ CssSelector(item="ul"),
2189
+ ),
2190
+ (CssSelector(item="menu"), CssSelector(item="ol"), CssSelector(item="dir")),
2191
+ (
2192
+ CssSelector(item="menu"),
2193
+ CssSelector(item="ol"),
2194
+ CssSelector(item="menu"),
2195
+ ),
2196
+ (CssSelector(item="menu"), CssSelector(item="ol"), CssSelector(item="ul")),
2197
+ (CssSelector(item="menu"), CssSelector(item="ul"), CssSelector(item="dir")),
2198
+ (
2199
+ CssSelector(item="menu"),
2200
+ CssSelector(item="ul"),
2201
+ CssSelector(item="menu"),
2202
+ ),
2203
+ (CssSelector(item="menu"), CssSelector(item="ul"), CssSelector(item="ul")),
2204
+ (CssSelector(item="ol"), CssSelector(item="dir"), CssSelector(item="dir")),
2205
+ (CssSelector(item="ol"), CssSelector(item="dir"), CssSelector(item="menu")),
2206
+ (CssSelector(item="ol"), CssSelector(item="dir"), CssSelector(item="ul")),
2207
+ (CssSelector(item="ol"), CssSelector(item="menu"), CssSelector(item="dir")),
2208
+ (
2209
+ CssSelector(item="ol"),
2210
+ CssSelector(item="menu"),
2211
+ CssSelector(item="menu"),
2212
+ ),
2213
+ (CssSelector(item="ol"), CssSelector(item="menu"), CssSelector(item="ul")),
2214
+ (CssSelector(item="ol"), CssSelector(item="ol"), CssSelector(item="dir")),
2215
+ (CssSelector(item="ol"), CssSelector(item="ol"), CssSelector(item="menu")),
2216
+ (CssSelector(item="ol"), CssSelector(item="ol"), CssSelector(item="ul")),
2217
+ (CssSelector(item="ol"), CssSelector(item="ul"), CssSelector(item="dir")),
2218
+ (CssSelector(item="ol"), CssSelector(item="ul"), CssSelector(item="menu")),
2219
+ (CssSelector(item="ol"), CssSelector(item="ul"), CssSelector(item="ul")),
2220
+ (CssSelector(item="ul"), CssSelector(item="dir"), CssSelector(item="dir")),
2221
+ (CssSelector(item="ul"), CssSelector(item="dir"), CssSelector(item="menu")),
2222
+ (CssSelector(item="ul"), CssSelector(item="dir"), CssSelector(item="ul")),
2223
+ (CssSelector(item="ul"), CssSelector(item="menu"), CssSelector(item="dir")),
2224
+ (
2225
+ CssSelector(item="ul"),
2226
+ CssSelector(item="menu"),
2227
+ CssSelector(item="menu"),
2228
+ ),
2229
+ (CssSelector(item="ul"), CssSelector(item="menu"), CssSelector(item="ul")),
2230
+ (CssSelector(item="ul"), CssSelector(item="ol"), CssSelector(item="dir")),
2231
+ (CssSelector(item="ul"), CssSelector(item="ol"), CssSelector(item="menu")),
2232
+ (CssSelector(item="ul"), CssSelector(item="ol"), CssSelector(item="ul")),
2233
+ (CssSelector(item="ul"), CssSelector(item="ul"), CssSelector(item="dir")),
2234
+ (CssSelector(item="ul"), CssSelector(item="ul"), CssSelector(item="menu")),
2235
+ (CssSelector(item="ul"), CssSelector(item="ul"), CssSelector(item="ul")),
2236
+ ): {"list_style_type": "square"},
2237
+ ((CssSelector(item="li"),),): {"display": "list-item"},
2238
+ ((CssSelector(item="details"),),): {
2239
+ "list_style_type": "disclosure-closed",
2240
+ "list_style_position": "inside",
2241
+ },
2242
+ ((CssSelector(item="details", attr="[open]"), CssSelector(item="summary")),): {
2243
+ "list_style_type": "disclosure-open"
2244
+ },
2245
+ ((CssSelector(item="summary"),),): {
2246
+ "display": "list-item",
2247
+ "font_weight": "bold",
2248
+ },
2249
+ # Dataframes for Jupyter
2250
+ ((CssSelector(item=".dataframe"),),): {"_pt_class": "dataframe"},
2251
+ (
2252
+ (CssSelector(item=".dataframe"), CssSelector(item="td")),
2253
+ (CssSelector(item=".dataframe"), CssSelector(item="th")),
2254
+ ): {
2255
+ "border_top_style": "hidden",
2256
+ "border_left_style": "hidden",
2257
+ "border_bottom_style": "hidden",
2258
+ "border_right_style": "hidden",
2259
+ "padding_left": "1em",
2260
+ },
2261
+ (
2262
+ (
2263
+ CssSelector(item=".dataframe"),
2264
+ CssSelector(item="th"),
2265
+ ),
2266
+ ): {
2267
+ "_pt_class": "dataframe,th",
2268
+ },
2269
+ (
2270
+ (
2271
+ CssSelector(item=".dataframe"),
2272
+ CssSelector(item="th", pseudo=":first-child"),
2273
+ ),
2274
+ (
2275
+ CssSelector(item=".dataframe"),
2276
+ CssSelector(item="th", pseudo=":last-child"),
2277
+ ),
2278
+ (
2279
+ CssSelector(item=".dataframe"),
2280
+ CssSelector(item="td", pseudo=":last-child"),
2281
+ ),
2282
+ ): {"padding_right": "1em"},
2283
+ ((CssSelector(item=".dataframe"), CssSelector(item="td")),): {
2284
+ "_pt_class": "dataframe,td bg:default"
2285
+ },
2286
+ (
2287
+ (
2288
+ CssSelector(item=".dataframe"),
2289
+ CssSelector(item="tr", pseudo=":nth-child(odd)"),
2290
+ CssSelector(item="td"),
2291
+ ),
2292
+ ): {"_pt_class": "dataframe,row-odd,td"},
2293
+ }
1916
2294
  }
1917
2295
 
1918
2296
 
@@ -1942,8 +2320,7 @@ class Node:
1942
2320
  self.before: Node | None = None
1943
2321
  self.after: Node | None = None
1944
2322
 
1945
- parent_theme = parent.theme if parent else None
1946
- self.theme = Theme(self, parent_theme=parent_theme)
2323
+ self.theme = Theme(self, parent_theme=parent.theme if parent else None)
1947
2324
 
1948
2325
  def _outer_html(self, d: int = 0, attrs: bool = True) -> str:
1949
2326
  dd = " " * d
@@ -1966,6 +2343,20 @@ class Node:
1966
2343
  s += f"{dd}{self.text}"
1967
2344
  return s
1968
2345
 
2346
+ @cached_property
2347
+ def preceding_text(self) -> str:
2348
+ """Return the text preceding this element."""
2349
+ s = ""
2350
+ parent = self.parent
2351
+ while parent and not parent.theme.d_blocky:
2352
+ parent = parent.parent
2353
+ if parent:
2354
+ for node in parent.renderable_descendents:
2355
+ if node is self:
2356
+ break
2357
+ s += node.text
2358
+ return s
2359
+
1969
2360
  @cached_property
1970
2361
  def text(self) -> str:
1971
2362
  """Get the element's computed text."""
@@ -1973,21 +2364,64 @@ class Node:
1973
2364
  if callable(transform := self.theme.text_transform):
1974
2365
  text = transform(text)
1975
2366
 
1976
- if not self.theme.preformatted:
1977
- # Strip whitespace
1978
- strippable = True
1979
- for i in text:
1980
- if i not in "\x20\x0a\x09\x0c\x0d":
1981
- strippable = False
1982
- break
1983
- if strippable:
1984
- if "\n" in text:
1985
- text = "\n"
1986
- else:
1987
- text = " "
2367
+ # if False and not (preformatted := self.theme.preformatted):
2368
+ if not (preformatted := self.theme.preformatted):
2369
+ # 1. All spaces and tabs immediately before and after a line break are ignored
2370
+ text = re.sub(r"(\s+(?=\n)|(?<=\n)\s+)", "", text, re.MULTILINE)
2371
+ # 2. All tab characters are handled as space characters
2372
+ text = text.replace("\t", " ")
2373
+ # 3. Line breaks are converted to spaces
2374
+ text = text.replace("\n", " ")
2375
+ # 4. any space immediately following another space is ignored
2376
+ # (even across two separate inline elements)
2377
+ text = re.sub(r"\s\s+", " ", text)
2378
+ if not (
2379
+ preceding_text := self.preceding_text
2380
+ ) or preceding_text.endswith(" "):
2381
+ text = text.lstrip(" ")
2382
+ # 5. Sequences of spaces at the beginning and end of an element are removed
2383
+ if text:
2384
+ parent = self.parent
2385
+ while (
2386
+ parent
2387
+ and parent.is_first_child_element
2388
+ and not parent.theme.d_blocky
2389
+ ):
2390
+ parent = parent.parent
2391
+ if parent and parent.theme.d_blocky:
2392
+ if text[0] == " " and (self.is_first_child_node):
2393
+ text = text[1:]
2394
+ if text:
2395
+ parent = self.parent
2396
+ while (
2397
+ parent
2398
+ and parent.is_last_child_element
2399
+ and not parent.theme.d_blocky
2400
+ ):
2401
+ parent = parent.parent
2402
+ if text[-1] == " " and (self.is_last_child_node):
2403
+ text = text[:-1]
2404
+
2405
+ # Remove space around text in block contexts
2406
+ if (
2407
+ text
2408
+ and (node := self.prev_node)
2409
+ and (node.theme.d_blocky or node.name == "br")
2410
+ ):
2411
+ text = text.lstrip("\x20\x0a\x09\x0c\x0d")
2412
+ if (
2413
+ text
2414
+ and (node := self.next_node)
2415
+ and (node.theme.d_blocky or node.name == "br")
2416
+ ):
2417
+ text = text.rstrip(" \t\r\n\x0c")
1988
2418
 
1989
- # Collapse whitespace
1990
- text = re.sub(r"\s+", " ", text.strip("\n").replace("\n", " "))
2419
+ elif preformatted and self.is_last_child_node:
2420
+ # TODO - align tabstops
2421
+ text = text.replace("\t", " ")
2422
+ # Remove one trailing newline
2423
+ if text[-1] == "\n":
2424
+ text = text[:-1]
1991
2425
 
1992
2426
  return text
1993
2427
 
@@ -1995,6 +2429,35 @@ class Node:
1995
2429
  """Find all child elements of a given tag type."""
1996
2430
  return [element for element in self.contents if element.name == tag]
1997
2431
 
2432
+ @cached_property
2433
+ def renderable_contents(self) -> list[Node]:
2434
+ """List the node's contents including '::before' and '::after' elements."""
2435
+ # Do not add '::before' and '::after' elements to themselves
2436
+ if self.name.startswith("::") or self.name == "text":
2437
+ return self.contents
2438
+
2439
+ contents = []
2440
+
2441
+ # Add ::before node
2442
+ before_node = Node(dom=self.dom, name="::before", parent=self)
2443
+ if text := before_node.theme.theme.get("content", "").strip('"').strip("'"):
2444
+ before_node.contents.append(
2445
+ Node(dom=self.dom, name="text", parent=before_node, text=text)
2446
+ )
2447
+ contents.append(before_node)
2448
+
2449
+ contents.extend(self.contents)
2450
+
2451
+ # Add ::after node
2452
+ after_node = Node(dom=self.dom, name="::after", parent=self)
2453
+ if text := after_node.theme.theme.get("content", "").strip('"').strip("'"):
2454
+ after_node.contents.append(
2455
+ Node(dom=self.dom, name="text", parent=after_node, text=text)
2456
+ )
2457
+ contents.append(after_node)
2458
+
2459
+ return contents
2460
+
1998
2461
  @property
1999
2462
  def descendents(self) -> Generator[Node, None, None]:
2000
2463
  """Yield all descendent elements."""
@@ -2002,9 +2465,26 @@ class Node:
2002
2465
  yield child
2003
2466
  yield from child.descendents
2004
2467
 
2468
+ @property
2469
+ def renderable_descendents(self) -> Generator[Node, None, None]:
2470
+ """Yield descendents, including pseudo and skipping inline elements."""
2471
+ for child in self.renderable_contents:
2472
+ if (
2473
+ child.theme.d_inline
2474
+ and child.renderable_contents
2475
+ and child.name != "text"
2476
+ ):
2477
+ yield from child.renderable_descendents
2478
+ # elif (
2479
+ # child.name == "text" and self.name != "::block" and self.theme.d_blocky
2480
+ # ):
2481
+ # yield Node(dom=self.dom, name="::block", parent=self, contents=[child])
2482
+ else:
2483
+ yield child
2484
+
2005
2485
  @cached_property
2006
2486
  def parents(self) -> list[Node]:
2007
- """Yield all descendent elements."""
2487
+ """Yield all parent elements."""
2008
2488
  parents = []
2009
2489
  parent = self.parent
2010
2490
  while parent is not None:
@@ -2157,17 +2637,20 @@ class Node:
2157
2637
  class CustomHTMLParser(HTMLParser):
2158
2638
  """An HTML parser."""
2159
2639
 
2640
+ soup: Node
2641
+ curr: Node
2642
+
2160
2643
  def __init__(self, dom: HTML) -> None:
2161
2644
  """Create a new parser instance."""
2162
2645
  super().__init__()
2163
2646
  self.dom = dom
2164
- self.curr = self.soup = Node(name="::root", dom=dom, parent=None, attrs=[])
2165
2647
 
2166
2648
  def parse(self, markup: str) -> Node:
2167
2649
  """Pare HTML markup."""
2168
- self.curr = self.soup = Node(dom=self.dom, name="::root", parent=None, attrs=[])
2650
+ soup = Node(name="::root", dom=self.dom, parent=None, attrs=[])
2651
+ self.curr = soup
2169
2652
  self.feed(markup)
2170
- return self.soup
2653
+ return soup
2171
2654
 
2172
2655
  def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
2173
2656
  """Open a new element."""
@@ -2187,13 +2670,7 @@ class CustomHTMLParser(HTMLParser):
2187
2670
  """Create data (text) elements."""
2188
2671
  self.autoclose()
2189
2672
  self.curr.contents.append(
2190
- Node(
2191
- dom=self.dom,
2192
- name="text",
2193
- parent=self.curr,
2194
- text=data,
2195
- attrs=[],
2196
- )
2673
+ Node(dom=self.dom, name="text", text=data, parent=self.curr, attrs=[])
2197
2674
  )
2198
2675
 
2199
2676
  def handle_endtag(self, tag: str) -> None:
@@ -2205,67 +2682,20 @@ class CustomHTMLParser(HTMLParser):
2205
2682
  self.curr = self.curr.parent
2206
2683
 
2207
2684
 
2208
- def parse_styles(soup: Node, base_url: UPath) -> CssSelectors:
2685
+ def parse_style_sheet(css_str: str, dom: HTML) -> None:
2209
2686
  """Collect all CSS styles from style tags."""
2210
- rules: CssSelectors = {}
2211
- for child in soup.descendents:
2212
- css_str = ""
2213
-
2214
- # In case of a <link> style, load the url
2215
- if (
2216
- child.name == "link"
2217
- and child.attrs.get("rel") == "stylesheet"
2218
- and (href := child.attrs.get("href"))
2219
- ):
2220
- if css_bytes := load_url(href, base_url):
2221
- css_str = css_bytes.decode()
2222
-
2223
- # In case of a <style> tab, load first child's text
2224
- elif child.name == "style":
2225
- if child.contents:
2226
- css_str = child.contents[0].text
2227
- else:
2228
- continue
2229
- # Remove whitespace and newlines
2230
- # css_str = css_str.strip().replace("\n", "")
2231
- css_str = re.sub(r"\s*\n\s*", " ", css_str)
2232
- # Remove comments
2233
- css_str = re.sub(r"\/\*[^\*]+\*\/", "", css_str)
2234
- # Replace ':before' and ':after' with '::before' and '::after'
2235
- css_str = re.sub("(?<!:):(?=before|after)", "::", css_str)
2236
- # Allow plain '@media screen' queries
2237
- css_str = re.sub(
2238
- r"""
2239
- @media\s+screen\s*{\s*
2240
- (.+? {
2241
- (?:[^:}]+\s*:\s*[^;}]+\s*;\s*)*
2242
- (?:[^:}]+\s*:\s*[^;}]+\s*;?\s*)?
2243
- })
2244
- \s*
2245
- }
2246
- """,
2247
- "\\1",
2248
- css_str,
2249
- 0,
2250
- flags=re.DOTALL | re.VERBOSE,
2251
- )
2252
- # Remove other media queries for now - TODO
2253
- # TODO - Far too slow sometimes!
2254
- css_str = re.sub(
2255
- r"""
2256
- @media.+?{\s*
2257
- (.+? {
2258
- (?:[^:}]+\s*:\s*[^;}]+\s*;\s*)*
2259
- (?:[^:}]+\s*:\s*[^;}]+\s*;?\s*)?
2260
- })
2261
- \s*
2262
- }
2263
- """,
2264
- "",
2265
- css_str,
2266
- 0,
2267
- flags=re.DOTALL | re.VERBOSE,
2268
- )
2687
+ dom_css = dom.css
2688
+ # Remove whitespace and newlines
2689
+ # css_str = css_str.strip().replace("\n", "")
2690
+ css_str = re.sub(r"\s*\n\s*", " ", css_str)
2691
+ # Remove comments
2692
+ css_str = re.sub(r"\/\*[^\*]+\*\/", "", css_str)
2693
+ # Replace ':before' and ':after' with '::before' and '::after'
2694
+ # for compatibility with old CSS and to make root selector work
2695
+ css_str = re.sub("(?<!:):(?=before|after|root)", "::", css_str)
2696
+
2697
+ def parse_part(condition: Filter, css_str: str) -> None:
2698
+ """Parse a group of CSS rules."""
2269
2699
  if css_str:
2270
2700
  css_str = css_str.replace("\n", "").strip()
2271
2701
  if css_str:
@@ -2281,54 +2711,176 @@ def parse_styles(soup: Node, base_url: UPath) -> CssSelectors:
2281
2711
  )
2282
2712
  for selector in map(str.strip, selectors.split(","))
2283
2713
  )
2714
+ rules = dom_css.setdefault(condition, {})
2284
2715
  if parsed_selectors in rules:
2285
2716
  rules[parsed_selectors].update(rule_content)
2286
2717
  else:
2287
2718
  rules[parsed_selectors] = rule_content
2288
2719
 
2289
- return rules
2720
+ # Split out nested at-rules - we need to process them separately
2721
+ for part in _AT_RULE_RE.split(css_str):
2722
+ if (
2723
+ part
2724
+ and part[0] == "@"
2725
+ and (m := _NESTED_AT_RULE_RE.match(part)) is not None
2726
+ ):
2727
+ m_dict = m.groupdict()
2728
+ # Process '@media' queries
2729
+ if m_dict["identifier"] == "media":
2730
+ # Split each query - separated by "or" or ","
2731
+ queries = re.split("(?: or |,)", m_dict["rule"])
2732
+ query_conditions: Filter = never
2733
+ for query in queries:
2734
+ # Each query can be the logical sum of multiple targets
2735
+ target_conditions: Filter = always
2736
+ for target in query.split(" and "):
2737
+ if (
2738
+ target_m := _MEDIA_QUERY_TARGET_RE.match(target)
2739
+ ) is not None:
2740
+ target_m_dict = target_m.groupdict()
2741
+ # Check for media type conditions
2742
+ if (media_type := target_m_dict["type"]) is not None:
2743
+ target_conditions &= (
2744
+ always if media_type in {"all", "screen"} else never
2745
+ )
2746
+ # Check for media feature confitions
2747
+ elif (
2748
+ media_feature := target_m_dict["feature"]
2749
+ ) is not None:
2750
+ target_conditions &= parse_media_condition(
2751
+ media_feature, dom
2752
+ )
2753
+ # Check for logical 'NOT' inverting the target condition
2754
+ if target_m_dict["invert"]:
2755
+ target_conditions = ~target_conditions
2756
+ # Logical OR of all media queries
2757
+ query_conditions |= target_conditions
2758
+ # Parse the @media CSS block
2759
+ parse_part(query_conditions, m_dict["part"])
2760
+ # If we are outinside an at-rule block, parse the CSS and always use it
2761
+ else:
2762
+ parse_part(always, part)
2290
2763
 
2291
2764
 
2292
- class HTML:
2293
- """A HTML formatted text renderer.
2765
+ def parse_media_condition(condition: str, dom: HTML) -> Filter:
2766
+ """Convert media rules to conditions."""
2767
+ condition = condition.replace(" ", "")
2768
+ result: Filter
2294
2769
 
2295
- Accepts a HTML string and renders it at a given width.
2296
- """
2770
+ for op_str, op_func in (("<=", le), (">=", ge), ("<", lt), (">", gt), ("=", eq)):
2771
+ if op_str in condition:
2772
+ operator = op_func
2773
+ feature, _, value = condition.partition(op_str)
2774
+ break
2775
+ else:
2776
+ feature, _, value = condition.partition(":")
2777
+ if feature.startswith("max-"):
2778
+ operator = le
2779
+ feature = feature[4:]
2780
+ elif feature.startswith("min-"):
2781
+ operator = ge
2782
+ feature = feature[4:]
2783
+ else:
2784
+ operator = eq
2297
2785
 
2298
- formatted_text: StyleAndTextTuples
2786
+ if feature == "width":
2787
+ result = Condition(
2788
+ lambda: operator(
2789
+ width := dom.width or 80,
2790
+ css_dimension(value, vertical=False, available=width) or 80,
2791
+ )
2792
+ )
2793
+ elif feature == "height":
2794
+ result = Condition(
2795
+ lambda: operator(
2796
+ height := dom.height or 24,
2797
+ css_dimension(value, vertical=True, available=height) or 24,
2798
+ )
2799
+ )
2800
+ elif feature == "aspect":
2801
+ result = Condition(
2802
+ lambda: operator(
2803
+ (width := (dom.width or 80)) / (height := (dom.height or 24)),
2804
+ (
2805
+ 80
2806
+ if (dim := css_dimension(value, vertical=False, available=width))
2807
+ is None
2808
+ else dim
2809
+ )
2810
+ / (
2811
+ 24
2812
+ if (dim := css_dimension(value, vertical=True, available=height))
2813
+ is None
2814
+ else dim
2815
+ ),
2816
+ )
2817
+ )
2299
2818
 
2300
- def render_ol_content(
2301
- self,
2302
- element: Node,
2303
- left: int = 0,
2304
- fill: bool = True,
2305
- align_content: bool = True,
2306
- ) -> StyleAndTextTuples:
2307
- """Render lists, adding item numbers to child <li> elements."""
2308
- # Assign a list index to each item. This can be set via the 'value' attributed
2309
- _curr = 0
2310
- for item in element.find_all("li"):
2311
- _curr += 1
2312
- _curr = int(item.attrs.setdefault("value", str(_curr)))
2313
- # Render list as normal
2314
- return self.render_node_content(
2315
- element=element,
2316
- left=left,
2317
- fill=fill,
2318
- align_content=align_content,
2819
+ elif feature == "device-width":
2820
+ result = Condition(
2821
+ lambda: operator(
2822
+ (
2823
+ output.get_size()
2824
+ if (output := get_app_session()._output)
2825
+ else Size(24, 80)
2826
+ ).columns,
2827
+ css_dimension(value, vertical=False, available=dom.width) or 80,
2828
+ )
2829
+ )
2830
+ elif feature == "device-height":
2831
+ result = Condition(
2832
+ lambda: operator(
2833
+ (
2834
+ output.get_size()
2835
+ if (output := get_app_session()._output)
2836
+ else Size(24, 80)
2837
+ ).rows,
2838
+ css_dimension(value, vertical=True, available=dom.height) or 24,
2839
+ )
2840
+ )
2841
+ elif feature == "device-aspect":
2842
+ result = Condition(
2843
+ lambda: operator(
2844
+ (
2845
+ size := (
2846
+ output.get_size()
2847
+ if (output := get_app_session()._output)
2848
+ else Size(24, 80)
2849
+ )
2850
+ ).columns
2851
+ / size.rows,
2852
+ (css_dimension(value, vertical=False, available=dom.width) or 1)
2853
+ / (css_dimension(value, vertical=True, available=dom.height) or 1),
2854
+ )
2319
2855
  )
2320
2856
 
2321
- render_ul_content = render_ol_content
2857
+ # elif feature == "color":
2858
+ # Check output color depth
2859
+
2860
+ else:
2861
+ result = always
2862
+
2863
+ return result
2864
+
2865
+
2866
+ class HTML:
2867
+ """A HTML formatted text renderer.
2868
+
2869
+ Accepts a HTML string and renders it at a given width.
2870
+ """
2322
2871
 
2323
2872
  def __init__(
2324
2873
  self,
2325
2874
  markup: str,
2326
- base: UPath | str | None = None,
2875
+ base: Path | str | None = None,
2327
2876
  width: int | None = None,
2328
2877
  height: int | None = None,
2329
2878
  collapse_root_margin: bool = False,
2330
2879
  fill: bool = True,
2880
+ css: CssSelectors | None = None,
2331
2881
  browser_css: CssSelectors | None = None,
2882
+ mouse_handler: Callable[[Node, MouseEvent], NotImplementedOrNone] | None = None,
2883
+ paste_fixed: bool = True,
2332
2884
  ) -> None:
2333
2885
  """Initialize the markdown formatter.
2334
2886
 
@@ -2342,87 +2894,120 @@ class HTML:
2342
2894
  collapse_root_margin: If :py:const:`True`, margins of the root element will
2343
2895
  be collapsed
2344
2896
  fill: Whether remaining space in block elements should be filled
2897
+ css: Base CSS to apply when rendering the HTML
2345
2898
  browser_css: The browser CSS to use
2899
+ mouse_handler: A mouse handler function to use when links are clicked
2900
+ paste_fixed: Whether fixed elements should be pasted over the output
2346
2901
 
2347
2902
  """
2348
- self.browser_css: CssSelectors = {
2349
- **(_BROWSER_CSS if browser_css is None else browser_css)
2350
- }
2351
- self.css: CssSelectors = {}
2352
-
2353
- self.markup = markup
2903
+ self.markup = markup.strip()
2354
2904
  self.base = UPath(base or ".")
2355
- self.width: int | None = None
2356
- self.height: int | None = None
2357
- self.collapse_root_margin = collapse_root_margin
2905
+ self.title = ""
2906
+
2907
+ self.browser_css = browser_css or _BROWSER_CSS
2908
+ self.css: CssSelectors = css or {}
2909
+
2910
+ self.render_count = 0
2911
+ self.width = width
2912
+ self.height = height
2913
+
2358
2914
  self.fill = fill
2915
+ self.collapse_root_margin = collapse_root_margin
2916
+ self.paste_fixed = paste_fixed
2359
2917
 
2360
- self.parser = CustomHTMLParser(self)
2918
+ self.mouse_handler = mouse_handler
2361
2919
 
2362
- self.element_theme_cache: SimpleCache[Hashable, dict[str, Any]] = SimpleCache()
2920
+ self.formatted_text: StyleAndTextTuples = []
2921
+ self.floats: dict[tuple[int, DiBool, DiInt], StyleAndTextTuples] = {}
2922
+ self.fixed: dict[tuple[int, DiBool, DiInt], StyleAndTextTuples] = {}
2923
+ self.fixed_mask: StyleAndTextTuples = []
2924
+ # self.images = []
2925
+ # self.anchors = []
2926
+
2927
+ self.assets_loaded = False
2928
+
2929
+ # Lazily load attributes
2930
+
2931
+ @cached_property
2932
+ def parser(self) -> CustomHTMLParser:
2933
+ """Load the HTML parser."""
2934
+ return CustomHTMLParser(self)
2363
2935
 
2364
- # Parse the markup
2365
- self.soup = self.parser.parse(markup.strip())
2936
+ @cached_property
2937
+ def soup(self) -> Node:
2938
+ """Parse the markup."""
2939
+ return self.parser.parse(self.markup)
2366
2940
 
2367
- # Parse the styles
2368
- self.css.update(parse_styles(self.soup, self.base))
2941
+ def load_assets(self) -> None:
2942
+ """Load CSS styles and image resources.
2369
2943
 
2370
- # Load images
2944
+ Do not touch element's themes!
2945
+ """
2371
2946
  for child in self.soup.descendents:
2372
- if child.name == "img" and (src := child.attrs.get("src")):
2373
- if data := load_url(src, self.base):
2374
- child.attrs["_data"] = data
2947
+ # Set title
2948
+ if child.name == "title":
2949
+ if contents := child.contents:
2950
+ self.title = contents[0].text
2951
+
2952
+ # In case of a <link> style, load the url
2953
+ elif (
2954
+ child.name == "link"
2955
+ and (
2956
+ (attrs := child.attrs).get("rel") == "stylesheet"
2957
+ or (attrs.get("rel") == "preload" and attrs.get("as") == "style")
2958
+ )
2959
+ and (href := attrs.get("href", ""))
2960
+ ):
2961
+ css_path = UPath(urljoin(str(self.base), href))
2962
+ try:
2963
+ css_str = css_path.read_text()
2964
+ except Exception:
2965
+ log.debug("Could not load file %s", css_path)
2966
+ else:
2967
+ parse_style_sheet(css_str, self)
2968
+
2969
+ # In case of a <style> tab, load first child's text
2970
+ elif child.name == "style":
2971
+ if child.contents:
2972
+ # Use unprocess text attribute to avoid loading the element's theme
2973
+ css_str = child.contents[0]._text
2974
+ parse_style_sheet(css_str, self)
2975
+
2976
+ # Load images
2977
+ elif child.name == "img" and (src := child.attrs.get("src")):
2978
+ data_path = UPath(urljoin(str(self.base), src))
2979
+ if data_path.exists():
2980
+ try:
2981
+ data = data_path.read_bytes()
2982
+ except Exception:
2983
+ log.info("Error loading file '%s'", data_path)
2984
+ else:
2985
+ if data:
2986
+ child.attrs["_data"] = data
2375
2987
  else:
2376
2988
  child.attrs["_missing"] = "true"
2377
2989
 
2378
- self.floats: dict[tuple[int, DiInt], StyleAndTextTuples] = {}
2379
- self.fixes: dict[tuple[int, DiInt], StyleAndTextTuples] = {}
2380
-
2381
- # Render the markup
2382
- self.render(width, height)
2383
-
2384
- def render_list_item_content(
2385
- self,
2386
- element: Node,
2387
- left: int = 0,
2388
- fill: bool = True,
2389
- align_content: bool = True,
2390
- ) -> StyleAndTextTuples:
2391
- """Render a list item."""
2392
- # Get element theme
2393
- theme = element.theme
2394
- # Get the bullet style
2395
- list_style = theme.list_style_type
2396
- bullet = list_style
2397
- if list_style == "decimal":
2398
- bullet = f"{element.attrs['value']}."
2399
- # Add bullet element
2400
- if bullet:
2401
- bullet_element = Node(
2402
- dom=self,
2403
- name="::marker",
2404
- parent=element,
2405
- contents=[Node(dom=self, name="text", parent=element, text=bullet)],
2406
- )
2407
- if theme.list_style_position == "inside":
2408
- element.contents.insert(0, bullet_element)
2409
- else:
2410
- element.marker = bullet_element
2411
- # Render the list item
2412
- ft = self.render_node_content(
2413
- element,
2414
- left=left,
2415
- fill=fill,
2416
- align_content=align_content,
2417
- )
2418
- return ft
2990
+ self.assets_loaded = True
2419
2991
 
2420
- def render(self, width: int | None, height: int | None) -> None:
2992
+ def render(self, width: int | None, height: int | None) -> StyleAndTextTuples:
2421
2993
  """Render the current markup at a given size."""
2422
- if not width or not height:
2994
+ no_w = width is None and self.width is None
2995
+ no_h = height is None and self.height is None
2996
+ if no_w or no_h:
2423
2997
  size = get_app_session().output.get_size()
2424
- self.width = width or size.columns
2425
- self.height = height or size.rows
2998
+ if no_w:
2999
+ width = size.columns
3000
+ if no_h:
3001
+ height = size.rows
3002
+ if width is not None:
3003
+ self.width = width
3004
+ if height is not None:
3005
+ self.height = height
3006
+ assert self.width is not None
3007
+ assert self.height is not None
3008
+
3009
+ if not self.assets_loaded:
3010
+ self.load_assets()
2426
3011
 
2427
3012
  ft = self.render_element(
2428
3013
  self.soup,
@@ -2431,29 +3016,64 @@ class HTML:
2431
3016
  fill=self.fill,
2432
3017
  )
2433
3018
 
2434
- # Draw floats
2435
- for (_, position), float_ft in sorted(self.floats.items()):
2436
- row = col = 0
2437
- if (top := position.top) is not None:
2438
- row = top
2439
- elif (bottom := position.bottom) is not None:
2440
- row = (
2441
- sum(1 for _ in split_lines(ft))
2442
- - sum(1 for _ in split_lines(float_ft))
2443
- - bottom
2444
- )
2445
- if (left := position.left) is not None:
2446
- col = left
2447
- elif (right := position.right) is not None:
2448
- row = max_line_width(ft) - max_line_width(float_ft) - right
2449
-
2450
- ft = paste(ft, float_ft, row, col)
2451
-
2452
3019
  # Apply "ReverseOverwrite"s
2453
3020
  ft = apply_reverse_overwrites(ft)
2454
3021
 
3022
+ # Apply floats and fixed elements
3023
+
3024
+ def _paste_floats(
3025
+ floats: dict[tuple[int, DiBool, DiInt], StyleAndTextTuples],
3026
+ lower_ft: StyleAndTextTuples,
3027
+ ) -> StyleAndTextTuples:
3028
+ """Paste floats on top of rendering."""
3029
+ lower_ft_height = None
3030
+ for (_, anchors, position), float_ft in sorted(floats.items()):
3031
+ row = col = 0
3032
+ if anchors.top:
3033
+ row = position.top
3034
+ elif anchors.bottom:
3035
+ if lower_ft_height is None:
3036
+ lower_ft_height = sum(1 for _ in split_lines(lower_ft))
3037
+ row = (
3038
+ lower_ft_height
3039
+ - sum(1 for _ in split_lines(float_ft))
3040
+ - position.bottom
3041
+ )
3042
+ if anchors.left:
3043
+ col = position.left
3044
+ elif anchors.right:
3045
+ row = (
3046
+ max_line_width(lower_ft)
3047
+ - max_line_width(float_ft)
3048
+ - position.right
3049
+ )
3050
+ lower_ft = paste(float_ft, lower_ft, row, col)
3051
+ return lower_ft
3052
+
3053
+ # Draw floats
3054
+ if self.floats:
3055
+ ft = _paste_floats(self.floats, ft)
3056
+
3057
+ # Paste floats onto a mask, then onto the rendering if required
3058
+ if self.fixed:
3059
+ assert self.width is not None
3060
+ assert self.height is not None
3061
+ fixed_mask = cast(
3062
+ "StyleAndTextTuples", [("", (" " * self.width) + "\n")] * self.height
3063
+ )
3064
+ fixed_mask = _paste_floats(self.fixed, fixed_mask)
3065
+ fixed_mask = apply_reverse_overwrites(fixed_mask)
3066
+ if self.paste_fixed:
3067
+ ft = paste(fixed_mask, ft, 0, 0, transparent=True)
3068
+ self.fixed_mask = fixed_mask
3069
+ else:
3070
+ self.fixed_mask = []
3071
+
3072
+ self.render_count += 1
2455
3073
  self.formatted_text = ft
2456
3074
 
3075
+ return ft
3076
+
2457
3077
  def render_element(
2458
3078
  self,
2459
3079
  element: Node,
@@ -2516,6 +3136,63 @@ class HTML:
2516
3136
  ft = [(style, text)]
2517
3137
  return ft
2518
3138
 
3139
+ def render_ol_content(
3140
+ self,
3141
+ element: Node,
3142
+ left: int = 0,
3143
+ fill: bool = True,
3144
+ align_content: bool = True,
3145
+ ) -> StyleAndTextTuples:
3146
+ """Render lists, adding item numbers to child <li> elements."""
3147
+ # Assign a list index to each item. This can be set via the 'value' attributed
3148
+ _curr = 0
3149
+ for item in element.find_all("li"):
3150
+ _curr += 1
3151
+ _curr = int(item.attrs.setdefault("value", str(_curr)))
3152
+ # Render list as normal
3153
+ return self.render_node_content(
3154
+ element=element,
3155
+ left=left,
3156
+ fill=fill,
3157
+ align_content=align_content,
3158
+ )
3159
+
3160
+ render_ul_content = render_ol_content
3161
+
3162
+ def render_list_item_content(
3163
+ self,
3164
+ element: Node,
3165
+ left: int = 0,
3166
+ fill: bool = True,
3167
+ align_content: bool = True,
3168
+ ) -> StyleAndTextTuples:
3169
+ """Render a list item."""
3170
+ # Get element theme
3171
+ theme = element.theme
3172
+ # Get the bullet style
3173
+ list_style = theme.list_style_type
3174
+ bullet = list_style
3175
+ if list_style == "decimal":
3176
+ bullet = f"{element.attrs['value']}."
3177
+ # Add bullet element
3178
+ if bullet:
3179
+ bullet_element = Node(dom=self, name="::marker", parent=element)
3180
+ bullet_element.contents.append(
3181
+ Node(dom=self, name="text", parent=bullet_element, text=bullet)
3182
+ )
3183
+ if theme.list_style_position == "inside":
3184
+ element.contents.insert(0, bullet_element)
3185
+ else:
3186
+ element.marker = bullet_element
3187
+ # Render the list item
3188
+ ft = self.render_node_content(
3189
+ element,
3190
+ left=left,
3191
+ fill=fill,
3192
+ align_content=align_content,
3193
+ )
3194
+ return ft
3195
+
2519
3196
  def render_table_content(
2520
3197
  self,
2521
3198
  element: Node,
@@ -2523,7 +3200,7 @@ class HTML:
2523
3200
  fill: bool = True,
2524
3201
  align_content: bool = True,
2525
3202
  ) -> StyleAndTextTuples:
2526
- """Render a list of parsed markdown elements representing a table element.
3203
+ """Render a HTML table element.
2527
3204
 
2528
3205
  Args:
2529
3206
  element: The list of parsed elements to render
@@ -2542,7 +3219,7 @@ class HTML:
2542
3219
  table_x_dim = Dimension(
2543
3220
  min=table_theme.min_width,
2544
3221
  preferred=table_theme.content_width if "width" in table_theme else None,
2545
- max=table_theme.max_width or table_theme.available_width,
3222
+ max=table_theme.max_width or table_theme.content_width,
2546
3223
  )
2547
3224
  table = Table(
2548
3225
  align=table_theme.text_align,
@@ -2579,7 +3256,6 @@ class HTML:
2579
3256
  or table_theme.available_width,
2580
3257
  )
2581
3258
  cell = row.new_cell(
2582
- # text=" ",
2583
3259
  text=self.render_node_content(
2584
3260
  td,
2585
3261
  left=0,
@@ -2610,18 +3286,19 @@ class HTML:
2610
3286
  for child in element.find_all("tfoot", recursive=False):
2611
3287
  render_rows(child.contents)
2612
3288
 
3289
+ # TODO - process <colgroup> elements
3290
+
2613
3291
  # Add cell contents
2614
3292
  if td_map:
2615
3293
  col_widths = table.calculate_col_widths()
2616
-
2617
3294
  for row in table.rows:
2618
3295
  for col_width, cell in zip(col_widths, row.cells):
2619
3296
  if td := td_map.get(cell):
2620
3297
  cell_padding = compute_padding(cell)
2621
3298
  available_width = (
2622
- (table_x_dim.max if cell.colspan > 1 else col_width)
2623
- - cell_padding.left
2624
- - cell_padding.right
3299
+ table_x_dim.max
3300
+ if cell.colspan > 1
3301
+ else col_width - cell_padding.left - cell_padding.right
2625
3302
  )
2626
3303
  td.theme.update_space(
2627
3304
  available_width, table_theme.available_height
@@ -2667,9 +3344,9 @@ class HTML:
2667
3344
  content_width = theme.content_width
2668
3345
  # content_height = theme.content_height
2669
3346
  src = str(element.attrs.get("src", ""))
2670
- path = UPath(src)
3347
+ path = self.base / src
2671
3348
 
2672
- if data := element.attrs.get("_data"):
3349
+ if not element.attrs.get("_missing") and (data := element.attrs.get("_data")):
2673
3350
  # Display it graphically
2674
3351
  format_ = get_format(path, default="png")
2675
3352
  cols, aspect = pixels_to_cell_size(*data_pixel_size(data, format_=format_))
@@ -2688,6 +3365,7 @@ class HTML:
2688
3365
  to="formatted_text",
2689
3366
  cols=cols,
2690
3367
  rows=rows,
3368
+ fg=theme.color,
2691
3369
  bg=theme.background_color,
2692
3370
  path=path,
2693
3371
  )
@@ -2699,18 +3377,21 @@ class HTML:
2699
3377
  ft = [(f"{theme.style} {x[0]}", *x[1:]) for x in ft]
2700
3378
 
2701
3379
  else:
2702
- ft = []
2703
-
2704
- if (alt := element.attrs.get("alt")) != "":
2705
- style = f"class:image,placeholder {theme.style}"
2706
- ft.append((style, "🌄"))
2707
- if content_width and content_width >= 7:
2708
- ft.extend(
2709
- [
2710
- (style, " "),
2711
- (style, (alt or (path.name if path else "Image"))),
2712
- ]
2713
- )
3380
+ style = f"class:image,placeholder {theme.style}"
3381
+ ft = [(style, "🌄")]
3382
+ if content_width and content_width >= 7:
3383
+ ft.extend(
3384
+ [
3385
+ (style, " "),
3386
+ (
3387
+ style,
3388
+ (
3389
+ element.attrs.get("alt")
3390
+ or (path.name if path else "Image")
3391
+ ),
3392
+ ),
3393
+ ]
3394
+ )
2714
3395
 
2715
3396
  return ft
2716
3397
 
@@ -2726,12 +3407,23 @@ class HTML:
2726
3407
  # HTMLParser clobber the case of element attributes
2727
3408
  # We fix the SVG viewBox here
2728
3409
  data = element._outer_html().replace(" viewbox=", " viewBox=")
2729
- # Render the image
3410
+ # Display it graphically
3411
+ cols, aspect = pixels_to_cell_size(*data_pixel_size(data, format_="svg"))
3412
+ # Scale down the image to fit to width
3413
+ if content_width := theme.content_width:
3414
+ if cols == 0:
3415
+ cols = content_width
3416
+ else:
3417
+ cols = min(content_width, cols)
3418
+ rows = int(cols * aspect)
3419
+ # Convert the image to formatted-text
2730
3420
  ft = convert(
2731
3421
  data=data,
2732
3422
  from_="svg",
2733
3423
  to="formatted_text",
2734
- cols=theme.content_width,
3424
+ cols=cols,
3425
+ rows=rows or None,
3426
+ fg=theme.color,
2735
3427
  bg=theme.background_color,
2736
3428
  )
2737
3429
  # Remove trailing new-lines
@@ -2755,7 +3447,7 @@ class HTML:
2755
3447
  dom=self,
2756
3448
  name="text",
2757
3449
  parent=element,
2758
- text=attrs.get("value", attrs.get("placeholder", "")),
3450
+ text=attrs.get("value", attrs.get("placeholder", " ")) or " ",
2759
3451
  ),
2760
3452
  )
2761
3453
  ft = self.render_node_content(
@@ -2766,16 +3458,6 @@ class HTML:
2766
3458
  )
2767
3459
  return ft
2768
3460
 
2769
- def render_br_content(
2770
- self,
2771
- element: Node,
2772
- left: int = 0,
2773
- fill: bool = True,
2774
- align_content: bool = True,
2775
- ) -> StyleAndTextTuples:
2776
- """Render line breaks."""
2777
- return [("", "\n")]
2778
-
2779
3461
  def render_node_content(
2780
3462
  self,
2781
3463
  element: Node,
@@ -2794,19 +3476,6 @@ class HTML:
2794
3476
  line_height = 1
2795
3477
  baseline = 0
2796
3478
 
2797
- # Add "::before" and "::after" nodes
2798
- # TODO - do we have to do this for every element?
2799
- for name, pos in (("::before", 0), ("::after", 1)):
2800
- if not element.name.startswith("::"):
2801
- content_node = Node(dom=element.dom, name=name, parent=element)
2802
- if text := content_node.theme.get("content", "").strip('"'):
2803
- content_node.contents.append(
2804
- Node(
2805
- dom=element.dom, name="text", parent=content_node, text=text
2806
- )
2807
- )
2808
- element.contents.insert(pos * len(element.contents), content_node)
2809
-
2810
3479
  parent_theme = element.theme
2811
3480
 
2812
3481
  d_blocky = d_inline = d_inline_block = False
@@ -2818,15 +3487,37 @@ class HTML:
2818
3487
 
2819
3488
  content_width = parent_theme.content_width
2820
3489
 
2821
- new_line = []
3490
+ new_line: StyleAndTextTuples = []
3491
+
3492
+ def flush() -> None:
3493
+ """Add the current line to the rendered output."""
3494
+ nonlocal new_line, ft, left, line_height, baseline
3495
+ if new_line:
3496
+ # Pad the new-line to form an alignable block
3497
+ new_line = pad(new_line, style=parent_theme.style)
3498
+ if ft:
3499
+ # Combine with the output
3500
+ ft = join_lines([ft, new_line]) if ft else new_line
3501
+ else:
3502
+ ft = new_line
3503
+ # Reset the new line
3504
+ left = 0
3505
+ line_height = 1
3506
+ baseline = 0
3507
+ new_line = []
2822
3508
 
2823
3509
  # Render each child node
2824
- for child in element.contents:
3510
+ for child in element.renderable_descendents:
2825
3511
  theme = child.theme
2826
3512
 
2827
3513
  if theme.skip:
2828
3514
  continue
2829
3515
 
3516
+ # Start a new line if we encounter a <br> element
3517
+ if child.name == "br":
3518
+ flush()
3519
+ continue
3520
+
2830
3521
  # We will start a new line if the previous item was a block
2831
3522
  if ft and d_blocky and last_char(ft) != "\n":
2832
3523
  line_height = 1
@@ -2836,12 +3527,16 @@ class HTML:
2836
3527
  d_blocky = theme.d_blocky
2837
3528
  d_inline = theme.d_inline
2838
3529
  d_inline_block = theme.d_inline_block
3530
+ preformatted = theme.preformatted
3531
+
3532
+ available_width = parent_theme.content_width
3533
+ available_height = parent_theme.content_height
2839
3534
 
2840
3535
  # Render the element
2841
3536
  rendering = self.render_element(
2842
3537
  child,
2843
- available_width=parent_theme.content_width,
2844
- available_height=parent_theme.content_height,
3538
+ available_width=available_width,
3539
+ available_height=available_height,
2845
3540
  left=0 if d_blocky or d_inline_block else left,
2846
3541
  fill=fill,
2847
3542
  align_content=align_content,
@@ -2853,16 +3548,15 @@ class HTML:
2853
3548
 
2854
3549
  # If the rendering was a positioned absolutely or fixed, store it and draw it later
2855
3550
  if theme.theme["position"] == "fixed":
2856
- # self.fixes[(theme.z_index, theme.position)] = rendering
2857
- self.floats[(theme.z_index, theme.position)] = rendering
3551
+ self.fixed[(theme.z_index, theme.anchors, theme.position)] = rendering
2858
3552
 
2859
3553
  # if theme.theme["position"] == "absolute":
2860
- # self.floats[(theme.z_index, theme.position)] = rendering
3554
+ # self.floats[(theme.z_index, theme.anchors, theme.position)] = rendering
2861
3555
 
2862
3556
  # if theme.theme["position"] == "relative":
2863
3557
  # ... TODO ..
2864
3558
 
2865
- elif theme.theme["float"] == "right":
3559
+ elif theme.floated == "right":
2866
3560
  lines = []
2867
3561
  for ft_left, ft_right in zip_longest(
2868
3562
  split_lines(pad(rendering, style=theme.style)),
@@ -2874,7 +3568,7 @@ class HTML:
2874
3568
  float_width_right = fragment_list_width(float_lines_right[0])
2875
3569
  continue
2876
3570
 
2877
- elif theme.theme["float"] == "left":
3571
+ elif theme.floated == "left":
2878
3572
  lines = []
2879
3573
  for ft_left, ft_right in zip_longest(
2880
3574
  lines,
@@ -2891,25 +3585,25 @@ class HTML:
2891
3585
  # output, which could have been an inline-block
2892
3586
 
2893
3587
  elif d_inline and (
3588
+ # parent_theme.d_inline or parent_theme.d_inline_block or preformatted
2894
3589
  parent_theme.d_inline
2895
- or parent_theme.d_inline_block
2896
- or theme.preformatted
3590
+ or preformatted
2897
3591
  ):
2898
3592
  new_line.extend(rendering)
2899
3593
 
2900
3594
  elif d_inline or d_inline_block:
2901
3595
  if d_inline:
2902
- tokens = fragment_list_to_words(rendering)
3596
+ tokens = list(fragment_list_to_words(rendering))
2903
3597
  else:
2904
3598
  tokens = [rendering]
2905
3599
 
2906
- tokens = list(tokens)
2907
-
2908
3600
  for token in tokens:
2909
3601
  token_lines = list(split_lines(token))
2910
3602
  token_width = max(fragment_list_width(line) for line in token_lines)
2911
3603
  token_height = len(token_lines)
2912
3604
 
3605
+ # Deal with floats
3606
+
2913
3607
  float_width_right = (
2914
3608
  fragment_list_width(float_lines_right[0])
2915
3609
  if float_lines_right
@@ -2921,9 +3615,15 @@ class HTML:
2921
3615
  else 0
2922
3616
  )
2923
3617
 
3618
+ # If we have floats, transform the current new line and add one row
3619
+ # from each active float
2924
3620
  if (
2925
- content_width - float_width_left - float_width_right
2926
- ) - left - token_width < 0:
3621
+ new_line
3622
+ and (content_width - float_width_left - float_width_right)
3623
+ - left
3624
+ - token_width
3625
+ < 0
3626
+ ):
2927
3627
  new_rows = list(split_lines(new_line))
2928
3628
  new_line_width = max(
2929
3629
  fragment_list_width(line) for line in new_rows
@@ -2957,27 +3657,31 @@ class HTML:
2957
3657
  how=parent_theme.text_align,
2958
3658
  width=line_width,
2959
3659
  style=parent_theme.style,
3660
+ placeholder="",
2960
3661
  ),
2961
3662
  *ft_right,
2962
3663
  ]
2963
3664
  )
2964
3665
 
2965
- ft = join_lines([ft, *transformed_rows]) if ft else new_line
2966
-
2967
3666
  float_lines_left = float_lines_left[line_height:]
2968
3667
  float_lines_right = float_lines_right[line_height:]
2969
3668
 
3669
+ # Manually flush the transformed lines
3670
+ if ft:
3671
+ ft = join_lines([ft, *transformed_rows])
3672
+ else:
3673
+ ft = join_lines(transformed_rows)
3674
+ baseline = 0
2970
3675
  new_rows = [[]]
2971
- new_line = []
2972
- line_height = 1
2973
3676
  left = 0
2974
- baseline = 0
3677
+ line_height = 1
3678
+ new_line = []
2975
3679
 
2976
3680
  if line_height == token_height == 1 or not new_line:
2977
3681
  new_line.extend(token)
2978
3682
  new_rows = [new_line]
2979
3683
  baseline = int(theme.vertical_align * (token_height - 1))
2980
-
3684
+ line_height = max(line_height, token_height)
2981
3685
  else:
2982
3686
  new_line, baseline = concat(
2983
3687
  ft_a=new_line,
@@ -2995,9 +3699,7 @@ class HTML:
2995
3699
  # end of the output
2996
3700
  else:
2997
3701
  # Flush the latest line
2998
- if new_line:
2999
- ft = join_lines([ft, new_line]) if ft else new_line
3000
- new_line = []
3702
+ flush()
3001
3703
  # Start block elements on a new line
3002
3704
  if ft and d_blocky and last_char(ft) != "\n":
3003
3705
  ft.append(("", "\n"))
@@ -3045,6 +3747,7 @@ class HTML:
3045
3747
  how=parent_theme.text_align,
3046
3748
  width=line_width,
3047
3749
  style=parent_theme.style + " nounderline",
3750
+ placeholder="",
3048
3751
  ),
3049
3752
  *ft_right,
3050
3753
  ]
@@ -3052,8 +3755,7 @@ class HTML:
3052
3755
  new_line = []
3053
3756
 
3054
3757
  # Flush any current lines
3055
- if new_line:
3056
- ft = join_lines([ft, new_line]) if ft else new_line
3758
+ flush()
3057
3759
 
3058
3760
  # Draw flex elements
3059
3761
  # if parent_theme.get("flex") and parent_theme.get("flex-direction") == "column":
@@ -3092,12 +3794,6 @@ class HTML:
3092
3794
  if d_inline:
3093
3795
  ft = apply_style(ft, theme.style)
3094
3796
 
3095
- # Remove trailing newline from the contents of pre-formatted elements
3096
- if preformatted and (
3097
- (parent_theme and not parent_theme.preformatted) or d_blocky
3098
- ):
3099
- ft = strip_one_trailing_newline(ft)
3100
-
3101
3797
  # If an element should not overflow it's width / height, truncate it
3102
3798
  if not d_inline and not preformatted:
3103
3799
  if theme.get("overflow_x") == "hidden":
@@ -3112,20 +3808,44 @@ class HTML:
3112
3808
  )
3113
3809
  else:
3114
3810
  ft = truncate(
3115
- ft, content_width, ignore_whitespace=True, style=theme.style
3811
+ ft,
3812
+ content_width,
3813
+ placeholder="",
3814
+ ignore_whitespace=True,
3815
+ style=theme.style,
3116
3816
  )
3117
3817
 
3118
- if theme.get("overflow_y") in {"hidden", "auto"}:
3119
- lines = []
3120
- for i, line in enumerate(split_lines(ft)):
3121
- if i <= content_height:
3122
- lines.append(line)
3123
- ft = join_lines(lines)
3818
+ # Truncate or expand the height
3819
+ overflow_y = theme.get("overflow_y") in {"hidden", "auto"}
3820
+ pad_height = d_blocky and theme.height is not None
3821
+ if overflow_y or pad_height:
3822
+ target_height = None
3823
+ if (min_height := theme.min_height) and min_height > content_height:
3824
+ target_height = min_height
3825
+ if (max_height := theme.max_height) and max_height < content_height:
3826
+ target_height = max_height
3827
+ elif height := theme.height:
3828
+ target_height = height
3829
+
3830
+ if target_height is not None:
3831
+ # Truncate elements with hidden overflows
3832
+ if overflow_y:
3833
+ lines = []
3834
+ for i, line in enumerate(split_lines(ft)):
3835
+ if i <= target_height:
3836
+ lines.append(line)
3837
+ else:
3838
+ lines = list(split_lines(ft))
3839
+
3840
+ # Pad height of block elements to theme height
3841
+ if pad_height and len(lines) < target_height:
3842
+ lines.extend([[]] * (target_height - len(lines)))
3843
+
3844
+ ft = join_lines(lines)
3124
3845
 
3125
3846
  # Align content
3126
- if align_content and (d_blocky or d_inline_block):
3847
+ if align_content and d_blocky:
3127
3848
  alignment = theme.text_align
3128
-
3129
3849
  if alignment != FormattedTextAlign.LEFT:
3130
3850
  ft = align(
3131
3851
  ft,
@@ -3133,10 +3853,11 @@ class HTML:
3133
3853
  width=None if d_inline_block else content_width,
3134
3854
  style=theme.style,
3135
3855
  ignore_whitespace=True,
3856
+ placeholder="",
3136
3857
  )
3137
3858
 
3138
3859
  # # Fill space around block elements so they fill the content width
3139
- if ft and (fill and d_blocky) or d_inline_block:
3860
+ if ft and ((fill and d_blocky and not theme.d_table) or d_inline_block):
3140
3861
  pad_width = None
3141
3862
  if d_blocky:
3142
3863
  pad_width = content_width
@@ -3145,11 +3866,12 @@ class HTML:
3145
3866
  pad_width = max_line_width(ft)
3146
3867
  else:
3147
3868
  pad_width = content_width
3869
+ style = theme.style
3148
3870
  ft = pad(
3149
3871
  ft,
3150
3872
  width=pad_width,
3151
3873
  char=" ",
3152
- style=theme.style,
3874
+ style=style,
3153
3875
  )
3154
3876
 
3155
3877
  # Use the rendered content width from now on for inline elements
@@ -3157,7 +3879,7 @@ class HTML:
3157
3879
  content_width = max_line_width(ft)
3158
3880
 
3159
3881
  # Add padding & border
3160
- if d_blocky or d_inline_block or d_inline:
3882
+ if d_blocky or d_inline_block:
3161
3883
  padding = theme.padding
3162
3884
  border_visibility = theme.border_visibility
3163
3885
  if (any(padding) or any(border_visibility)) and not (
@@ -3173,6 +3895,33 @@ class HTML:
3173
3895
  padding=padding,
3174
3896
  )
3175
3897
 
3898
+ # Draw borders and padding on text inside inline elements
3899
+ elif element.name == "text":
3900
+ padding = theme.padding
3901
+ border_visibility = theme.border_visibility
3902
+ if (
3903
+ padding.left
3904
+ or padding.right
3905
+ or border_visibility.left
3906
+ or border_visibility.right
3907
+ ):
3908
+ if not element.is_first_child_node:
3909
+ border_visibility = border_visibility._replace(left=False)
3910
+ padding = padding._replace(left=0)
3911
+ if not element.is_last_child_node:
3912
+ border_visibility = border_visibility._replace(right=False)
3913
+ padding = padding._replace(right=0)
3914
+ if any(padding) or any(border_visibility):
3915
+ ft = add_border(
3916
+ ft,
3917
+ style=f"{theme.style} nounderline",
3918
+ border_grid=theme.border_grid,
3919
+ width=content_width if not ft else None,
3920
+ border_visibility=border_visibility,
3921
+ border_style=theme.border_style,
3922
+ padding=padding,
3923
+ )
3924
+
3176
3925
  # The "::marker" element is drawn in the margin, before any padding
3177
3926
  # If the element has no margin, it can end up in the parent's padding
3178
3927
  # We use [ReverseOverwrite] fragments to ensure the marker is ignored
@@ -3194,13 +3943,15 @@ class HTML:
3194
3943
  parent_style = parent_theme.style if parent_theme else ""
3195
3944
 
3196
3945
  # Render the margin
3197
- if d_blocky and theme.margin_auto:
3946
+ # if d_blocky and (alignment := theme.block_align) != FormattedTextAlign.LEFT:
3947
+ if (alignment := theme.block_align) != FormattedTextAlign.LEFT:
3198
3948
  # Center block contents if margin_left and margin_right are "auto"
3199
3949
  ft = align(
3200
3950
  ft,
3201
- how=FormattedTextAlign.CENTER,
3951
+ how=alignment,
3202
3952
  width=theme.available_width,
3203
3953
  style=parent_style,
3954
+ placeholder="",
3204
3955
  )
3205
3956
 
3206
3957
  elif any(margin := theme.margin):
@@ -3214,18 +3965,40 @@ class HTML:
3214
3965
 
3215
3966
  # Ensure hidden content is blank and styled like the parent
3216
3967
  if theme.hidden:
3217
- ft = [
3218
- (
3219
- parent_style,
3220
- "\n".join([" " * len(x) for x in text.split("\n")]),
3221
- )
3222
- for style, text, *_ in ft
3223
- ]
3968
+ ft = cast(
3969
+ "StyleAndTextTuples",
3970
+ [
3971
+ (
3972
+ parent_style,
3973
+ "\n".join([" " * len(x) for x in text.split("\n")]),
3974
+ *rest,
3975
+ )
3976
+ for style, text, *rest in ft
3977
+ ],
3978
+ )
3979
+
3980
+ # Apply mouse handler to links
3981
+ if (
3982
+ (parent := element.parent)
3983
+ and parent.name == "a"
3984
+ and callable(handler := self.mouse_handler)
3985
+ and (href := parent.attrs.get("href"))
3986
+ ):
3987
+ element.attrs["_link_path"] = self.base / href
3988
+ ft = cast(
3989
+ "StyleAndTextTuples",
3990
+ [
3991
+ (style, text, *(rest or [partial(handler, element)]))
3992
+ for style, text, *rest in ft
3993
+ ],
3994
+ )
3224
3995
 
3225
3996
  return ft
3226
3997
 
3227
3998
  def __pt_formatted_text__(self) -> StyleAndTextTuples:
3228
3999
  """Return formatted text."""
4000
+ if not self.formatted_text:
4001
+ self.render(width=None, height=None)
3229
4002
  return self.formatted_text
3230
4003
 
3231
4004
 
@@ -3237,18 +4010,19 @@ if __name__ == "__main__":
3237
4010
  from prompt_toolkit.styles.style import Style
3238
4011
 
3239
4012
  from euporie.core.app import BaseApp
4013
+ from euporie.core.path import parse_path
3240
4014
  from euporie.core.style import HTML_STYLE
3241
4015
 
3242
- path = UPath(sys.argv[1])
4016
+ path = parse_path(sys.argv[1])
3243
4017
 
3244
4018
  with create_app_session(input=BaseApp.load_input(), output=BaseApp.load_output()):
3245
4019
  with set_app(BaseApp()):
3246
- with path.open() as f:
3247
- html = HTML(
3248
- path.open().read(),
4020
+ print_formatted_text(
4021
+ HTML(
4022
+ path.read_text(),
3249
4023
  base=path,
3250
4024
  collapse_root_margin=False,
3251
4025
  fill=True,
3252
- )
3253
-
3254
- print_formatted_text(html, style=Style(HTML_STYLE))
4026
+ ),
4027
+ style=Style(HTML_STYLE),
4028
+ )