Skip to content

Commit

Permalink
Add CSS Parser
Browse files Browse the repository at this point in the history
  • Loading branch information
KevinL10 committed Aug 28, 2023
1 parent 3a5817d commit 61a6d9e
Show file tree
Hide file tree
Showing 9 changed files with 333 additions and 175 deletions.
4 changes: 1 addition & 3 deletions browser/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
# ruff: noqa: F401

from browser.request import request
# ruff: noqa: F401
33 changes: 29 additions & 4 deletions browser/graphics.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from browser.parser import HTMLParser, style
from browser.request import request
from browser.parser import HTMLParser, CSSParser, style, Element, cascade_priority
from browser.request import URL
from browser.constants import VSTEP, HEIGHT, WIDTH, SCROLL_STEP
from browser.layout import DocumentLayout
from browser.utils import tree_to_list
import tkinter
import tkinter.font

Expand All @@ -22,6 +23,10 @@ def __init__(self):
self.window.bind("<Button-4>", self.scrollup)
self.window.bind("<Button-5>", self.scrolldown)

with open("tests/browser.css", "r") as f:
self.default_style_sheet = CSSParser(f.read()).parse()


def scrollup(self, e):
self.scroll = max(0, self.scroll - SCROLL_STEP)
self.draw()
Expand All @@ -47,9 +52,29 @@ def draw(self):

# Renders the contents of the url to the canvas
def load(self, url):
headers, body = request(url)
url = URL(url)
headers, body = url.request()
self.node = HTMLParser(body).parse()
style(self.node)

links = [
node.attributes["href"]
for node in tree_to_list(self.node, [])
if isinstance(node, Element)
and node.tag == "link"
and node.attributes.get("rel") == "stylesheet"
and "href" in node.attributes
]

rules = self.default_style_sheet.copy()

for link in links:
head, body = url.resolve(link).request()
rules.extend(CSSParser(body).parse())


# Sort first by CSS priority, then by file order
# Higher priority rules will come later in the rules array
style(self.node, sorted(rules, key=cascade_priority))

self.document = DocumentLayout(self.node)
self.document.layout()
Expand Down
81 changes: 31 additions & 50 deletions browser/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ def get_font(size, weight, slant):

# DrawText represents a display_list command to draw text to screen
class DrawText:
def __init__(self, x1, y1, text, font):
def __init__(self, x1, y1, text, font, color):
self.top = y1
self.left = x1
self.text = text
self.font = font
self.bottom = y1 + font.metrics("linespace")
self.color = color

# Draws text to the given canvas
def execute(self, scroll, canvas):
Expand All @@ -33,6 +34,7 @@ def execute(self, scroll, canvas):
text=self.text,
anchor="nw",
font=self.font,
fill=self.color,
)


Expand Down Expand Up @@ -119,8 +121,8 @@ def paint(self, display_list):
for child in self.children:
child.paint(display_list)

for x, y, word, font in self.display_list:
display_list.append(DrawText(x, y, word, font))
for x, y, word, font, color in self.display_list:
display_list.append(DrawText(x, y, word, font, color))

"""
Returns the type of layout of the given HTML nodes
Expand Down Expand Up @@ -195,58 +197,37 @@ def layout(self):

def recurse(self, node):
if isinstance(node, Text):
self.add_text(node)
for word in node.text.split():
self.word(node, word)
else:
self.open_tag(node.tag)
if node.tag == "br":
self.flush()

for child in node.children:
self.recurse(child)
self.close_tag(node.tag)

"""
Updates the current weight/style/size based on the given open tag
"""

def open_tag(self, tag):
if tag == "i":
self.style = "italic"
elif tag == "b":
self.weight = "bold"
elif tag == "small":
self.size -= 2
elif tag == "big":
self.size += 4
elif tag == "sub":
self.size //= 2
elif tag == "br":
# Add the word in the current node to the display list
def word(self, node, word):
color = node.style["color"]
font = self.get_font(node)
# font = get_font(self.size, self.weight, self.style)
w = font.measure(word)
if self.cursor_x + w > self.width:
self.flush()
self.line.append((self.cursor_x, word, font, color))
self.cursor_x += w + font.measure(" ")

"""
Updates the current weight/style/size based on the given close tag
"""
# Return the font corresponding to the current node's style attributes
def get_font(self, node):
weight = node.style["font-weight"]
style = node.style["font-style"]
font_size = int(float(node.style["font-size"][:-2]) * 0.75)

def close_tag(self, tag):
if tag == "i":
self.style = "roman"
elif tag == "b":
self.weight = "normal"
elif tag == "small":
self.size += 2
elif tag == "big":
self.size -= 4
elif tag == "sub":
self.size *= 2
elif tag == "p":
self.flush()
self.cursor_y += VSTEP
# Translate to Tk units
if style == "normal":
style = "roman"

def add_text(self, token):
font = get_font(self.size, self.weight, self.style)
for word in token.text.split():
w = font.measure(word)
if self.cursor_x + w > self.width:
self.flush()
self.line.append((self.cursor_x, word, font))
self.cursor_x += w + font.measure(" ")
return get_font(font_size, weight, style)

# Flushes the current display line:
# - Aligns the word along the bottom of the line
Expand All @@ -256,15 +237,15 @@ def flush(self):
if not self.line:
return

metrics = [font.metrics() for x, word, font in self.line]
metrics = [font.metrics() for x, word, font, color in self.line]
max_ascent = max([metric["ascent"] for metric in metrics])
max_descent = max([metric["descent"] for metric in metrics])
baseline = self.cursor_y + 1.25 * max_ascent

for rel_x, word, font in self.line:
for rel_x, word, font, color in self.line:
x = self.x + rel_x
y = self.y + baseline - font.metrics("ascent")
self.display_list.append((x, y, word, font))
self.display_list.append((x, y, word, font, color))

self.cursor_y = baseline + 1.25 * max_descent
self.cursor_x = 0
Expand Down
118 changes: 112 additions & 6 deletions browser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ def __init__(self, text, parent):
def __repr__(self):
return self.text


class Element:
def __init__(self, tag, attributes, parent):
self.tag = tag
Expand Down Expand Up @@ -147,13 +148,15 @@ def finish(self):

return self.unfinished.pop()


def print_tree(node, indent=0):
print(" " * indent, node, node.style, getattr(node, "attributes", ""))
for child in node.children:
print_tree(child, indent + 2)


# A parser class containing parsing functions to advance through the text
# Note: all of the functions assume no leading whitespace and strip trailing whitespace
class CSSParser:
# Initialize the CSS parser with the text being parse and the position set to 0
def __init__(self, s):
Expand Down Expand Up @@ -205,34 +208,137 @@ def ignore_until(self, chars):
# Advance through the current "style" attribute
def body(self):
pairs = {}
while self.i < len(self.s):
while self.i < len(self.s) and self.s[self.i] != "}":
try:
prop, value = self.pair()
pairs[prop] = value
self.whitespace()
self.literal(";")
self.whitespace()
except Exception as e:
print("Error parsing body:", e) # DEBUG
print("Error parsing body:", self.s, e) # DEBUG

# Skip to the next set of properties if encounter parsing error
why = self.ignore_until([";"])
why = self.ignore_until([";", "}"])
if why == ";":
self.literal(";")
self.whitespace()
else:
break
return pairs
return pairs

# Advance through the current CSS selector (tag or descendant)
def selector(self):
out = TagSelector(self.word().lower())
self.whitespace()
if self.i < len(self.s) and self.s[self.i] != "{":
out = DescendantSelector(out, TagSelector(self.word().lower()))
self.whitespace()
return out

# Parse a CSS file
def parse(self):
rules = []
while self.i < len(self.s):
# Skip the whole rule if encounter parsing error
try:
self.whitespace()
selector = self.selector()
self.literal("{")
self.whitespace()
body = self.body()
self.literal("}")
rules.append((selector, body))
except Exception:
why = self.ignore_until(["}"])
if why == "}":
self.literal("}")
self.whitespace()
else:
break
return rules


INHERITED_PROPERTIES = {
"font-size": "16px",
"font-style": "normal",
"font-weight": "normal",
"color": "black",
}


# Recursively add the style property-value attributes to the given node and its children
def style(node):
# 1. Add inherited CSS properties from default and parent
# 2. Add CSS properties from stylesheet files
# 3. Add CSS properties for element-specific style attributes
def style(node, rules):
node.style = {}

# Add default font properties to the current node
# Note: this must come before specific rules in CSS files
for property, value in INHERITED_PROPERTIES.items():
if node.parent:
node.style[property] = node.parent.style[property]
else:
node.style[property] = value

for selector, body in rules:
if not selector.matches(node):
continue
for property, value in body.items():
node.style[property] = value

if isinstance(node, Element) and "style" in node.attributes:
pairs = CSSParser(node.attributes["style"]).body()
for property, value in pairs.items():
node.style[property] = value


# Resolve font percentage sign
if node.style["font-size"].endswith("%"):
if node.parent:
parent_font_size = node.parent.style["font-size"]
else:
parent_font_size = INHERITED_PROPERTIES["font-size"]

node_pct = float(node.style["font-size"][:-1]) / 100
parent_px = float(parent_font_size[:-2])
node.style["font-size"] = str(parent_px * node_pct) + "px"

for child in node.children:
style(child)
style(child, rules)


# Represents a CSS tag selector,
class TagSelector:
def __init__(self, tag):
self.tag = tag
self.priority = 1

def matches(self, node):
return isinstance(node, Element) and self.tag == node.tag


class DescendantSelector:
def __init__(self, ancestor, descendant):
self.ancestor = ancestor
self.descendant = descendant
self.priority = self.ancestor.priority + self.descendant.priority

def matches(self, node):
if not self.descendant.matches(node):
return False

while node.parent:
if self.ancestor.matches(node.parent):
return True
node = node.parent

return False


# Returns the priority of the given CSS rule.
# Higher priority should override lower priority.
def cascade_priority(rule):
selector, body = rule
return selector.priority
Loading

0 comments on commit 61a6d9e

Please sign in to comment.