browser.py

Working through the exercises at browser.engineering
git clone https://git.sr.ht/~jbauer/browser.py
Log | Files | Refs | README | LICENSE

commit b6c26acd163aa5fdb4341bd8dd8dde6d4fc909eb
parent 09431ff1b1110ccdd0908ec891e2aba25feafa2b
Author: Jake Bauer <jbauer@paritybit.ca>
Date:   Thu, 23 Feb 2023 17:00:44 -0500

Implement Chapter 6

Diffstat:
Abrowser.css | 10++++++++++
Mbrowser.py | 295++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 250 insertions(+), 55 deletions(-)

diff --git a/browser.css b/browser.css @@ -0,0 +1,10 @@ +body { background-color: white; } +pre { background-color: lightgray; } +a { color: blue; } +i { font-style: italic; } +em { font-style: italic; } +b { font-weight: bold; } +strong { font-weight: bold; } +small { font-size: 90% } +big { font-size: 110% } +h1 { font-size: 150% } diff --git a/browser.py b/browser.py @@ -10,8 +10,6 @@ WIDTH, HEIGHT = 800, 600 HSTEP, VSTEP = 13, 18 SCROLLSTEP = 100 -EXEC_TIME = 0 - BLOCK_ELEMENTS = [ "html", "body", "article", "section", "nav", "aside", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "header", @@ -21,6 +19,12 @@ BLOCK_ELEMENTS = [ "legend", "details", "summary" ] +INHERITED_PROPERTIES = { + "font-size": "16px", + "font-style": "normal", + "font-weight": "normal", + "color": "black", +} class Browser: def __init__(self): @@ -30,11 +34,14 @@ class Browser: self.window, width=WIDTH, height=HEIGHT, + bg="white", ) self.canvas.pack() self.window.bind("<Down>", self.scrolldown) self.window.bind("<Up>", self.scrollup) self.document = None + with open("browser.css") as f: + self.default_style_sheet = CSSParser(f.read()).parse() def scrolldown(self, e): @@ -49,7 +56,8 @@ class Browser: def load(self, url): - overall_start = timer() + time_to_first_draw = timer() + start = timer() headers, body = request(url) end = timer() @@ -61,6 +69,24 @@ class Browser: print('{0:.5f}'.format(end - start), "- Lexed response body") start = timer() + rules = self.default_style_sheet.copy() + links = [node.attributes["href"] + for node in tree_to_list(self.nodes, []) + if isinstance(node, Element) + and node.tag == "link" + and "href" in node.attributes + and node.attributes.get("rel") == "stylesheet"] + for link in links: + try: + header, body = request(resolve_url(link, url)) + except: + continue + rules.extend(CSSParser(body).parse()) + style(self.nodes, sorted(rules, key=cascade_priority)) + end = timer() + print('{0:.5f}'.format(end - start), "- Styled nodes") + + start = timer() self.document = DocumentLayout(self.nodes) self.document.layout() end = timer() @@ -73,7 +99,7 @@ class Browser: end = timer() print('{0:.5f}'.format(end - start), "- Canvas drawn") - print('{0:.5f}'.format(timer() - EXEC_TIME), "- Time to first draw") + print('{0:.5f}'.format(timer() - time_to_first_draw), "- Time to first draw") def draw(self): @@ -139,10 +165,6 @@ class BlockLayout: else: self.cursor_x = 0 self.cursor_y = 0 - self.weight = "normal" - self.slant = "roman" - self.size = 10 - self.display = True self.line = [] self.recurse(self.node) @@ -159,25 +181,26 @@ class BlockLayout: def paint(self, display_list): - if isinstance(self.node, Element) and self.node.tag == "pre": + bgcolor = self.node.style.get("background-color", "transparent") + if bgcolor != "transparent": x2, y2 = self.x + self.width, self.y + self.height - rect = DrawRect(self.x, self.y, x2, y2, "gray") + rect = DrawRect(self.x, self.y, x2, y2, bgcolor) display_list.append(rect) - 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)) for child in self.children: child.paint(display_list) 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]) 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_x = 0 self.line = [] max_descent = max([metric["descent"] for metric in metrics]) @@ -185,64 +208,40 @@ class BlockLayout: def text(self, node): - if not self.display: return - font = get_font(self.size, self.weight, self.slant) + color = node.style["color"] + weight = node.style["font-weight"] + style = node.style["font-style"] + # Convert CSS normal to Tk roman + if style == "normal": style = "roman" + # Convert CSS Pixels to Tk points + size = int(float(node.style["font-size"][:-2]) * 0.75) + font = get_font(size, weight, style) for word in node.text.split(): w = font.measure(word) if self.cursor_x + w >= self.width: self.flush() - self.line.append((self.cursor_x, word, font)) + self.line.append((self.cursor_x, word, font, color)) self.cursor_x += w + font.measure(" ") - def open_tag(self, tag): - if tag == "i" or tag == "em": - self.slant = "italic" - elif tag == "b" or tag == "strong": - self.weight = "bold" - elif tag == "small": - self.size -= 2 - elif tag == "big": - self.size += 4 - elif tag == "style": - self.display = False - elif tag == "br": - self.flush() - - - def close_tag(self, tag): - if tag == "i" or tag == "em": - self.slant = "roman" - elif tag == "b" or tag == "strong": - self.weight = "normal" - elif tag == "small": - self.size += 2 - elif tag == "big": - self.size -= 4 - elif tag == "style": - self.display = True - elif tag == "p": - self.flush() - self.cursor_y += VSTEP - - def recurse(self, node): if isinstance(node, Text): self.text(node) 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) 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 def execute(self, scroll, canvas): @@ -251,6 +250,7 @@ class DrawText: text=self.text, font=self.font, anchor='nw', + fill=self.color, ) @@ -398,13 +398,133 @@ class HTMLParser: return self.finish() +class CSSParser: + def __init__(self, s): + self.s = s + self.i = 0 + + + def whitespace(self): + while self.i < len(self.s) and self.s[self.i].isspace(): + self.i += 1 + + + def word(self): + start = self.i + while self.i < len(self.s): + if self.s[self.i].isalnum() or self.s[self.i] in "#-.%": + self.i +=1 + else: + break + assert self.i > start + return self.s[start:self.i] + + + def literal(self, literal): + assert self.i < len(self.s) and self.s[self.i] == literal + self.i += 1 + + + def pair(self): + prop = self.word() + self.whitespace() + self.literal(":") + self.whitespace() + val = self.word() + return prop.lower(), val + + + def body(self): + pairs = {} + while self.i < len(self.s) and self.s[self.i] != "}": + try: + prop, val = self.pair() + pairs[prop] = val + self.whitespace() + self.literal(";") + self.whitespace() + except AssertionError: + why = self.ignore_until([";", "}"]) + if why == ";": + self.literal(";") + self.whitespace() + else: + break + return pairs + + + def ignore_until(self, chars): + while self.i < len(self.s): + if self.s[self.i] in chars: + return self.s[self.i] + else: + self.i += 1 + + + def selector(self): + out = TagSelector(self.word().lower()) + self.whitespace() + while self.i < len(self.s) and self.s[self.i] != "{": + tag = self.word() + descendant = TagSelector(tag.lower()) + out = DescendantSelector(out, descendant) + self.whitespace() + return out + + + def parse(self): + rules = [] + while self.i < len(self.s): + try: + self.whitespace() + selector = self.selector() + self.literal("{") + self.whitespace() + body = self.body() + self.literal("}") + rules.append((selector, body)) + except AssertionError: + why = self.ignore_until(["}"]) + if why == "{": + self.literal("}") + self.whitespace() + else: + break + return rules + + +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 = ancestor.priority + 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 + + def request(url): scheme,url = url.split("://", 1) assert scheme in ["http", "https"], \ "Unknown scheme {}".format(scheme) url_components = url.split("/", 1) host = url_components[0] - path = "/" + (url_components[1] if len(url_components) > 2 else "") + path = "/" + (url_components[1] if len(url_components) > 1 else "") s = socket.socket( family=socket.AF_INET, @@ -470,11 +590,76 @@ def layout_mode(node): return "block" +def style(node, rules): + node.style = {} + for property, default_value in INHERITED_PROPERTIES.items(): + if node.parent: + node.style[property] = node.parent.style[property] + else: + node.style[property] = default_value + for selector, body in rules: + if not selector.matches(node): continue + for property, value in body.items(): + computed_value = compute_style(node, property, value) + if not computed_value: continue + node.style[property] = computed_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 + for child in node.children: + style(child, rules) + +def cascade_priority(rule): + selector, body = rule + return selector.priority + + +def compute_style(node, property, value): + if property == "font-size": + if value.endswith("px"): + return value + elif value.endswith("%"): + if node.parent: + parent_font_size = node.parent.style["font-size"] + else: + parent_font_size = INHERITED_PROPERTIES["font-size"] + node_pct = float(value[:-1]) / 100 + parent_px = float(parent_font_size[:-2]) + return str(node_pct * parent_px) + "px" + else: + return None + else: + return value + + +def tree_to_list(tree, list): + list.append(tree) + for child in tree.children: + tree_to_list(child, list) + return list + + +def resolve_url(url, current): + if "://" in url: + return url + elif url.startswith("/"): + scheme, hostpath = current.split("://", 1) + host, oldpath = hostpath.split("/",) + return scheme + "://" + host + url + else: + dir, _ = current.rsplit("/", 1) + while url.startswith("../"): + url = url[3:] + if dir.count("/") == 2: continue + dir, _ = dir.rsplit("/", 1) + return dir + "/" + url + + if __name__ == "__main__": - EXEC_TIME = timer() import sys if len(sys.argv) < 2: url = "http://www.paritybit.ca/meta.html" else: url = sys.argv[1] - # if url.endswith("/"): url += "index.html" Browser().load(url) tkinter.mainloop() +