CSS support (font-bold, font-italic, underline, text-[size], font-mono, text-[color], bg-[color]

This commit is contained in:
Face
2025-07-26 15:32:29 +03:00
parent c55d574a11
commit 942ae1b534
19 changed files with 573 additions and 102 deletions

255
Scripts/B9/CSSParser.gd Normal file
View File

@@ -0,0 +1,255 @@
class_name CSSParser
extends RefCounted
class CSSRule:
var selector: String
var event_prefix: String = ""
var properties: Dictionary = {}
var specificity: int = 0
func init(sel: String = ""):
selector = sel
parse_selector()
calculate_specificity()
func parse_selector():
if selector.contains(":"):
var parts = selector.split(":", false, 1)
if parts.size() == 2:
event_prefix = parts[0]
selector = parts[1]
func calculate_specificity():
specificity = 1
if event_prefix.length() > 0:
specificity += 10
class CSSStylesheet:
var rules: Array[CSSRule] = []
func add_rule(rule: CSSRule):
rules.append(rule)
func get_styles_for_element(tag_name: String, event: String = "") -> Dictionary:
var styles = {}
# Sort rules by specificity
var applicable_rules: Array[CSSRule] = []
for rule in rules:
if selector_matches(rule, tag_name, event):
applicable_rules.append(rule)
applicable_rules.sort_custom(func(a, b): return a.specificity < b.specificity)
# Apply styles in order of specificity
for rule in applicable_rules:
for property in rule.properties:
styles[property] = rule.properties[property]
return styles
func selector_matches(rule: CSSRule, tag_name: String, event: String = "") -> bool:
if rule.selector != tag_name:
return false
if rule.event_prefix.length() > 0:
return rule.event_prefix == event
return event.length() == 0
var stylesheet: CSSStylesheet
var css_text: String
func init(css_content: String = ""):
stylesheet = CSSStylesheet.new()
css_text = css_content
func parse() -> void:
if css_text.is_empty():
return
var cleaned_css = preprocess_css(css_text)
var rules = extract_rules(cleaned_css)
for rule_data in rules:
var rule = parse_rule(rule_data)
if rule:
stylesheet.add_rule(rule)
func preprocess_css(css: String) -> String:
# Remove comments
var regex = RegEx.new()
regex.compile("/\\*.*?\\*/")
css = regex.sub(css, "", true)
# Normalize whitespace
regex.compile("\\s+")
css = regex.sub(css, " ", true)
return css.strip_edges()
func extract_rules(css: String) -> Array:
var rules = []
var current_pos = 0
while current_pos < css.length():
var brace_start = css.find("{", current_pos)
if brace_start == -1:
break
var brace_end = find_matching_brace(css, brace_start)
if brace_end == -1:
break
var selector_part = css.substr(current_pos, brace_start - current_pos).strip_edges()
var properties_part = css.substr(brace_start + 1, brace_end - brace_start - 1).strip_edges()
# Handle multiple selectors separated by commas
var selectors = selector_part.split(",")
for selector in selectors:
rules.append({
"selector": selector.strip_edges(),
"properties": properties_part
})
current_pos = brace_end + 1
return rules
func find_matching_brace(css: String, start_pos: int) -> int:
var brace_count = 0
var pos = start_pos
while pos < css.length():
match css[pos]:
"{":
brace_count += 1
"}":
brace_count -= 1
if brace_count == 0:
return pos
pos += 1
return -1
func parse_rule(rule_data: Dictionary) -> CSSRule:
var rule = CSSRule.new()
rule.selector = rule_data.selector
rule.init(rule.selector)
var properties_text = rule_data.properties
var utility_classes = properties_text.split(" ")
for utility_name in utility_classes:
utility_name = utility_name.strip_edges()
if utility_name.is_empty():
continue
parse_utility_class(rule, utility_name)
return rule
func parse_utility_class(rule: CSSRule, utility_name: String) -> void:
# Handle color classes like text-[#ff0000]
if utility_name.begins_with("text-[") and utility_name.ends_with("]"):
var color_value = extract_bracket_content(utility_name, 5) # after 'text-'
rule.properties["color"] = parse_color(color_value)
return
# Handle background color classes like bg-[#ff0000]
if utility_name.begins_with("bg-[") and utility_name.ends_with("]"):
var color_value = extract_bracket_content(utility_name, 3) # after 'bg-'
var color = parse_color(color_value)
rule.properties["background-color"] = color
return
# Handle font weight
if utility_name == "font-bold":
rule.properties["font-bold"] = true
return
# Handle font mono
if utility_name == "font-mono":
rule.properties["font-mono"] = true
return
# Handle font style italic
if utility_name == "font-italic":
rule.properties["font-italic"] = true
return
# Handle underline
if utility_name == "underline":
rule.properties["underline"] = true
return
# Handle text size classes
match utility_name:
"text-xs": rule.properties["font-size"] = 12
"text-sm": rule.properties["font-size"] = 14
"text-base": rule.properties["font-size"] = 16
"text-lg": rule.properties["font-size"] = 18
"text-xl": rule.properties["font-size"] = 20
"text-2xl": rule.properties["font-size"] = 24
"text-3xl": rule.properties["font-size"] = 30
"text-4xl": rule.properties["font-size"] = 36
"text-5xl": rule.properties["font-size"] = 48
"text-6xl": rule.properties["font-size"] = 60
# Handle more utility classes as needed
# Add more cases here for other utilities
# Helper to extract content inside first matching brackets after a given index
func extract_bracket_content(str: String, start_idx: int) -> String:
var open_idx = str.find("[", start_idx)
if open_idx == -1:
return ""
var close_idx = str.find("]", open_idx)
if close_idx == -1:
return ""
return str.substr(open_idx + 1, close_idx - open_idx - 1)
func parse_color(color_string: String) -> Color:
print("DEBUG: parsing color: ", color_string)
color_string = color_string.strip_edges()
# Handle hex colors
if color_string.begins_with("#"):
return Color.from_string(color_string, Color.WHITE)
# Handle rgb/rgba
if color_string.begins_with("rgb"):
var regex = RegEx.new()
regex.compile("rgba?\\(([^)]+)\\)")
var result = regex.search(color_string)
if result:
var values = result.get_string(1).split(",")
if values.size() >= 3:
var r = values[0].strip_edges().to_float() / 255.0
var g = values[1].strip_edges().to_float() / 255.0
var b = values[2].strip_edges().to_float() / 255.0
var a = 1.0
if values.size() >= 4:
a = values[3].strip_edges().to_float()
return Color(r, g, b, a)
# Handle named colors
# TODO: map to actual Tailwind colors
match color_string.to_lower():
"red": return Color.RED
"green": return Color.GREEN
"blue": return Color.BLUE
"white": return Color.WHITE
"black": return Color.BLACK
"yellow": return Color.YELLOW
"cyan": return Color.CYAN
"magenta": return Color.MAGENTA
_: return Color.from_string(color_string, Color.WHITE)
static func parse_inline_style(style_string: String) -> Dictionary:
var parser = CSSParser.new()
var rule_data = {
"selector": "",
"properties": style_string
}
var rule = parser.parse_rule(rule_data)
return rule.properties if rule else {}

View File

@@ -0,0 +1 @@
uid://cffcjsiwgyln

View File

@@ -34,49 +34,8 @@ class HTMLElement:
func get_preserved_text() -> String:
return text_content
func get_bbcode_formatted_text() -> String:
var result = ""
var has_previous_content = false
if text_content.length() > 0:
result += get_collapsed_text()
has_previous_content = true
for child in children:
var child_content = ""
match child.tag_name:
"b":
child_content = "[b]" + child.get_bbcode_formatted_text() + "[/b]"
"i":
child_content = "[i]" + child.get_bbcode_formatted_text() + "[/i]"
"u":
child_content = "[u]" + child.get_bbcode_formatted_text() + "[/u]"
"small":
child_content = "[font_size=20]" + child.get_bbcode_formatted_text() + "[/font_size]"
"mark":
child_content = "[bgcolor=#FFFF00]" + child.get_bbcode_formatted_text() + "[/bgcolor]"
"code":
child_content = "[font_size=20][code]" + child.get_bbcode_formatted_text() + "[/code][/font_size]"
"span":
child_content = child.get_bbcode_formatted_text()
"a":
var href = child.get_attribute("href")
if href.length() > 0:
child_content = "[color=#1a0dab][url=%s]%s[/url][/color]" % [href, child.get_bbcode_formatted_text()]
else:
child_content = child.get_bbcode_formatted_text()
_:
child_content = child.get_bbcode_formatted_text()
if has_previous_content and child_content.length() > 0:
result += " "
result += child_content
if child_content.length() > 0:
has_previous_content = true
return result
func get_bbcode_formatted_text(parser: HTMLParser = null) -> String:
return HTMLParser.get_bbcode_with_styles(self, {}, parser) # Pass empty dict for default
func is_inline_element() -> bool:
return tag_name in ["b", "i", "u", "small", "mark", "code", "span", "a", "input"]
@@ -85,6 +44,8 @@ class ParseResult:
var root: HTMLElement
var all_elements: Array[HTMLElement] = []
var errors: Array[String] = []
var css_parser: CSSParser = null
var inline_styles: Dictionary = {}
func _init():
root = HTMLElement.new("document")
@@ -94,6 +55,21 @@ var xml_parser: XMLParser
var bitcode: PackedByteArray
var parse_result: ParseResult
var DEFAULT_CSS := """
h1 { text-5xl font-bold }
h2 { text-4xl font-bold }
h3 { text-3xl font-bold }
h4 { text-2xl font-bold }
h5 { text-xl font-bold }
b { font-bold }
i { font-italic }
u { underline }
small { text-xl }
mark { bg-[#FFFF00] }
code { text-xl font-mono }
a { text-[#1a0dab] }
"""
func _init(data: PackedByteArray):
bitcode = data
xml_parser = XMLParser.new()
@@ -103,7 +79,7 @@ func _init(data: PackedByteArray):
func parse() -> ParseResult:
xml_parser.open_buffer(bitcode)
var element_stack: Array[HTMLElement] = [parse_result.root]
while xml_parser.read() != ERR_FILE_EOF:
match xml_parser.get_node_type():
XMLParser.NODE_ELEMENT:
@@ -113,6 +89,9 @@ func parse() -> ParseResult:
current_parent.children.append(element)
parse_result.all_elements.append(element)
if element.tag_name == "style":
handle_style_element(element)
if not element.is_self_closing:
element_stack.append(element)
@@ -127,6 +106,53 @@ func parse() -> ParseResult:
return parse_result
func handle_style_element(style_element: HTMLElement) -> void:
# Check if it's an external stylesheet
var src = style_element.get_attribute("src")
if src.length() > 0:
# TODO: Handle external CSS loading when Network module is available
print("External CSS not yet supported: " + src)
return
# Handle inline CSS - we'll get the text content when parsing is complete
# For now, create a parser that will be populated later
if not parse_result.css_parser:
parse_result.css_parser = CSSParser.new()
parse_result.css_parser.init()
func process_styles() -> void:
if not parse_result.css_parser:
return
# Collect all style element content
var css_content = DEFAULT_CSS
var style_elements = find_all("style")
for style_element in style_elements:
if style_element.get_attribute("src").is_empty():
css_content += style_element.text_content + "\n"
print("Processing CSS: ", css_content)
# Parse CSS if we have any
if css_content.length() > 0:
parse_result.css_parser.css_text = css_content
parse_result.css_parser.parse()
for child: CSSParser.CSSRule in parse_result.css_parser.stylesheet.rules:
print("INFO: for selector \"%s\" we have props: %s" % [child.selector, child.properties])
func get_element_styles(element: HTMLElement, event: String = "") -> Dictionary:
var styles = {}
# Apply CSS rules
if parse_result.css_parser:
styles.merge(parse_result.css_parser.stylesheet.get_styles_for_element(element.tag_name, event))
# Apply inline styles (higher priority)
var inline_style = element.get_attribute("style")
if inline_style.length() > 0:
var inline_parsed = CSSParser.parse_inline_style(inline_style)
styles.merge(inline_parsed)
return styles
# Creates element from CURRENT xml parser node
func create_element() -> HTMLElement:
var element = HTMLElement.new(xml_parser.get_node_name())
@@ -228,3 +254,64 @@ func get_all_scripts() -> Array[String]:
func get_all_stylesheets() -> Array[String]:
return get_attribute_values("style", "src")
func apply_element_styles(node: Control, element: HTMLElement, parser: HTMLParser) -> void:
var styles = parser.get_element_styles(element)
if node.get("rich_text_label"):
var label = node.rich_text_label
var text = HTMLParser.get_bbcode_with_styles(element, styles, parser)
label.text = text
static func get_bbcode_with_styles(element: HTMLElement, styles: Dictionary, parser: HTMLParser) -> String:
var text = ""
if element.text_content.length() > 0:
text += element.get_collapsed_text()
for child in element.children:
var child_styles = styles
if parser != null:
child_styles = parser.get_element_styles(child)
var child_content = HTMLParser.get_bbcode_with_styles(child, child_styles, parser)
match child.tag_name:
"b":
if child_styles.has("font-bold") and child_styles["font-bold"]:
child_content = "[b]" + child_content + "[/b]"
"i":
if child_styles.has("font-italic") and child_styles["font-italic"]:
child_content = "[i]" + child_content + "[/i]"
"u":
if child_styles.has("underline") and child_styles["underline"]:
child_content = "[u]" + child_content + "[/u]"
"small":
if child_styles.has("font-size"):
child_content = "[font_size=%d]%s[/font_size]" % [child_styles["font-size"], child_content]
else:
child_content = "[font_size=20]%s[/font_size]" % child_content
"mark":
if child_styles.has("bg"):
var color = child_styles["bg"]
if typeof(color) == TYPE_COLOR:
color = color.to_html(false)
child_content = "[bgcolor=#%s]%s[/bgcolor]" % [color, child_content]
else:
child_content = "[bgcolor=#FFFF00]%s[/bgcolor]" % child_content
"code":
if child_styles.has("font-size"):
child_content = "[font_size=%d][code]%s[/code][/font_size]" % [child_styles["font-size"], child_content]
else:
child_content = "[font_size=20][code]%s[/code][/font_size]" % child_content
"a":
var href = child.get_attribute("href")
var color = "#1a0dab"
if child_styles.has("color"):
var c = child_styles["color"]
if typeof(c) == TYPE_COLOR:
color = "#" + c.to_html(false)
else:
color = str(c)
if href.length() > 0:
child_content = "[color=%s][url=%s]%s[/url][/color]" % [color, href, child_content]
_:
pass
text += child_content
return text