Skip to content

Commit

Permalink
Implement LineLayout, TextLayout, and links
Browse files Browse the repository at this point in the history
  • Loading branch information
KevinL10 committed Aug 29, 2023
1 parent 61a6d9e commit 22b8182
Show file tree
Hide file tree
Showing 9 changed files with 203 additions and 68 deletions.
54 changes: 47 additions & 7 deletions browser/graphics.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from browser.parser import HTMLParser, CSSParser, style, Element, cascade_priority
from browser.request import URL
from browser.parser import (
HTMLParser,
CSSParser,
style,
Element,
cascade_priority,
Text,
)
from browser.constants import VSTEP, HEIGHT, WIDTH, SCROLL_STEP
from browser.layout import DocumentLayout
from browser.layout import DocumentLayout, print_layout
from browser.utils import tree_to_list
import tkinter
import tkinter.font
Expand All @@ -16,17 +22,21 @@ def __init__(self):
self.canvas.pack()
self.scroll = 0

self.url = None

self.window.bind("<Up>", self.scrollup)
self.window.bind("<Down>", self.scrolldown)

# Click binding
self.window.bind("<Button-1>", self.click)

# Linux mouse wheel bindings
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 @@ -37,6 +47,35 @@ def scrolldown(self, e):
self.scroll = min(self.scroll + SCROLL_STEP, max_y)
self.draw()

# Handle clicking on links (i.e. <a href=...> elements)
def click(self, e):
# Note that e.x and e.y are relative to the browser window
# so we must add the self.scroll amount to y
x, y = e.x, e.y
y += self.scroll

# TODO: find a faster way to calculate which element is clicked
objs = [
obj
for obj in tree_to_list(self.document, [])
if obj.x <= x < obj.x + obj.width and obj.y <= y < obj.y + obj.height
]

if not objs:
return

# Take the most specific element (i.e. the last one, usually a text node)
# Then, find any <a href=...> elements in the parent chain
element = objs[-1].node
while element:
if isinstance(element, Text):
pass
elif element.tag == "a" and "href" in element.attributes:
url = self.url.resolve(element.attributes["href"])
return self.load(url)

element = element.parent

# Draws the to-be-displayed text; calculates the position
# of each character by subtracting the current scroll
# (i.e. how far the user is down the page)
Expand All @@ -52,7 +91,7 @@ def draw(self):

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

Expand All @@ -66,19 +105,20 @@ def load(self, url):
]

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

print_layout(self.document)

# The display_list consists of commands like DrawText and DrawRect
self.display_list = []
self.document.paint(self.display_list)
Expand Down
157 changes: 111 additions & 46 deletions browser/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,21 @@ def __init__(self, node: Element, parent: Element, previous: "BlockLayout"):
self.children = []
self.display_list = []

def __repr__(self):
if isinstance(self.node, Text):
return "<BlockLayout text>"
else:
return f"<BlockLayout tag={self.node.tag}>"

def paint(self, display_list):
bgcolor = self.node.style.get("background-color", "transparent")
if bgcolor != "transparent":
x2, y2 = self.x + self.width, self.y + self.height
display_list.append(DrawRect(self.x, self.y, x2, y2, bgcolor))

# if isinstance(self.node, Element) and self.node.tag == "pre":
# x2, y2 = self.x + self.width, self.y + self.height
# display_list.append(DrawRect(self.x, self.y, x2, y2, "gray"))

for child in self.children:
child.paint(display_list)

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 @@ -165,31 +164,17 @@ def layout(self):
self.children.append(next)
previous = next
else:
self.display_list = []
self.cursor_x = 0
self.cursor_y = 0
self.weight = "normal"
self.style = "roman"
self.size = 16

# A list of (x, word, font) tuples representing the current line
# The final display_list is computed by aligning the words along the
# bottom of the line
self.line = []

self.new_line()
self.recurse(self.node)

# Flushy any remaining layout elements
self.flush()

# Recursively layout each block child
for child in self.children:
child.layout()

if mode == "block":
self.height = sum([child.height for child in self.children])
else:
self.height = self.cursor_y
# If block-mode, then the height is the sum of its children HTML elements
# Otherwise, if inline-mode, then the height is the sum of the children
# LineLayout objects
self.height = sum([child.height for child in self.children])

"""
Recursively layout the parsed HTML tree
Expand All @@ -201,22 +186,35 @@ def recurse(self, node):
self.word(node, word)
else:
if node.tag == "br":
self.flush()
self.new_line()

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

# 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.new_line()

# Add the word to the current LineLayout object
line = self.children[-1]
text = TextLayout(node, word, line, self.previous_word)
line.children.append(text)
self.previous_word = text

self.cursor_x += w + font.measure(" ")

# Creates a new line object
def new_line(self):
self.previous_word = None
self.cursor_x = 0

previous_line = self.children[-1] if self.children else None
self.children.append(LineLayout(self.node, self, previous_line))

# Return the font corresponding to the current node's style attributes
def get_font(self, node):
weight = node.style["font-weight"]
Expand All @@ -229,28 +227,89 @@ def get_font(self, node):

return get_font(font_size, weight, style)

# Flushes the current display line:
# - Aligns the word along the bottom of the line
# - Add all of the words in the current line to the display_list
# - Updates cursor_x and cursor_y
def flush(self):
if not self.line:

# Stores the layout for a line of words
class LineLayout:
def __init__(self, node, parent, previous):
self.node = node
self.parent = parent
self.previous = previous
self.children = []

def __repr__(self):
return "<LineLayout>"

def layout(self):
self.width = self.parent.width
self.x = self.parent.x

if self.previous:
self.y = self.previous.y + self.previous.height
else:
self.y = self.parent.y

# Layout each of the words (TextLayout objects) in the current line
for word in self.children:
word.layout()

# TODO: how should you handle the case of multiple <br> tags in a row?
if not self.children:
self.height = 0
return

metrics = [font.metrics() for x, word, font, color in self.line]
metrics = [word.font.metrics() for word in self.children]
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
baseline = self.y + 1.25 * max_ascent

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, color))
for word in self.children:
word.y = baseline - word.font.metrics("ascent")

self.cursor_y = baseline + 1.25 * max_descent
self.cursor_x = 0
self.height = 1.25 * (max_ascent + max_descent)

# Paints all of the children TextLayout objects
def paint(self, display_list):
for child in self.children:
child.paint(display_list)

self.line = []

# Stores the layout for a single word
class TextLayout:
def __init__(self, node, word, parent, previous):
self.node = node
self.word = word
self.parent = parent
self.previous = previous
self.children = []

def __repr__(self):
return f'<TextLayout word="{self.word}">'

# Layout the current text object by computing its font, x, width, and height
# Note: the text's y coordinate is already computed by the LineLayout object
def layout(self):
# Set the font for the current word
weight = self.node.style["font-weight"]
style = self.node.style["font-style"]
font_size = int(float(self.node.style["font-size"][:-2]) * 0.75)
if style == "normal":
style = "roman"
self.font = get_font(font_size, weight, style)

# Compute the position and dimensions of the word
self.width = self.font.measure(self.word)

if self.previous:
self.x = self.previous.x + self.previous.width + self.font.measure(" ")
else:
self.x = self.parent.x

self.height = self.font.metrics("linespace")

# Paints the current word to the display list
def paint(self, display_list):
color = self.node.style["color"]
display_list.append(DrawText(self.x, self.y, self.word, self.font, color))


class DocumentLayout:
Expand All @@ -270,3 +329,9 @@ def layout(self):
self.width = WIDTH - 2 * HSTEP
child.layout()
self.height = child.height + 2 * VSTEP


def print_layout(node, indent=0):
print(" " * indent, node)
for child in node.children:
print_layout(child, indent + 2)
5 changes: 3 additions & 2 deletions browser/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from browser.graphics import Browser
from browser.request import URL
import sys
import tkinter

if __name__ == "__main__":

browser = Browser()
if len(sys.argv) == 2:
browser.load(sys.argv[1])
browser.load(URL(sys.argv[1]))
else:
browser.load("https://browser.engineering/styles.html")
browser.load(URL("https://browser.engineering/styles.html"))

# browser.load("https://www.w3.org/Style/CSS/Test/CSS1/current/test5526c.htm")

Expand Down
2 changes: 1 addition & 1 deletion browser/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def request(self, num_redirects=0):

# Handle redirects
if 300 <= int(status) <= 399:
return self.request(headers["location"], num_redirects + 1)
return URL(headers["location"]).request(num_redirects + 1)

assert status == b"200", f"{status}: {explanation}"
body = response.read()
Expand Down
9 changes: 9 additions & 0 deletions browser6.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
pre {
background-color: gray;
}

a { color: blue; }
i { font-style: italic; }
b { font-weight: bold; }
small { font-size: 90%; }
big { font-size: 110%; }
4 changes: 4 additions & 0 deletions tests/browser.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ pre {
background-color: gray;
}

h1 {
font-size: 300%;
}

a { color: blue; }
i { font-style: italic; }
b { font-weight: bold; }
Expand Down
Loading

0 comments on commit 22b8182

Please sign in to comment.