Guest User

Toybrowser 136

a guest
Oct 6th, 2025
19
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 67.59 KB | None | 0 0
  1. import socket
  2. import tkinter as tk
  3. from tkinter import ttk
  4. import struct
  5. import re
  6. import json
  7.  
  8. import warnings
  9. from urllib3.exceptions import InsecureRequestWarning
  10. warnings.simplefilter('ignore', InsecureRequestWarning)
  11.  
  12. import random
  13. import sys
  14. import traceback
  15. import urllib.parse
  16. import requests
  17. from tkinter.font import Font
  18. from PIL import Image, ImageTk
  19. import math
  20. import threading
  21.  
  22. ###############################################################################
  23. # 1) DNS + URL PARSING
  24. ###############################################################################
  25.  
  26. def resolve_hostname_dns(hostname, dns_server="8.8.8.8", port=53, timeout=5):
  27. """
  28. If 'hostname' is numeric, skip DNS. Otherwise do a naive DNS A-record lookup.
  29. """
  30. hostname = hostname.strip()
  31. try:
  32. socket.inet_aton(hostname) # numeric => skip DNS
  33. return hostname
  34. except OSError:
  35. pass
  36.  
  37. tid = random.randint(0, 65535)
  38. header = struct.pack(">HHHHHH", tid, 0x0100, 1, 0, 0, 0)
  39. qname = b""
  40. for part in hostname.split("."):
  41. qname += bytes([len(part)]) + part.encode("ascii")
  42. question = qname + b"\x00" + struct.pack(">HH", 1, 1)
  43. query = header + question
  44.  
  45. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  46. s.settimeout(timeout)
  47. try:
  48. s.sendto(query, (dns_server, port))
  49. data, _ = s.recvfrom(512)
  50. except:
  51. s.close()
  52. return None
  53. s.close()
  54.  
  55. resp_tid, flags, qdcount, ancount, nscount, arcount = struct.unpack(">HHHHHH", data[:12])
  56. if resp_tid != tid:
  57. return None
  58.  
  59. idx = 12
  60. # skip question
  61. while idx < len(data) and data[idx] != 0:
  62. idx += 1
  63. idx += 1
  64. idx += 4
  65.  
  66. ip_addr = None
  67. while idx < len(data):
  68. if idx >= len(data):
  69. break
  70. if data[idx] & 0xC0 == 0xC0:
  71. idx += 2
  72. else:
  73. while idx < len(data) and data[idx] != 0:
  74. idx += 1
  75. idx += 1
  76. if idx + 10 > len(data):
  77. break
  78. rtype, rclass, rttl, rdlength = struct.unpack(">HHIH", data[idx:idx+10])
  79. idx += 10
  80. if rtype == 1 and rclass == 1 and rdlength == 4:
  81. ip_bytes = data[idx:idx+4]
  82. ip_addr = ".".join(map(str, ip_bytes))
  83. break
  84. idx += rdlength
  85.  
  86. return ip_addr
  87.  
  88. class ParsedURL:
  89. def __init__(self, scheme="http", host="", port=80, path="/"):
  90. self.scheme = scheme
  91. self.host = host
  92. self.port = port
  93. self.path = path
  94.  
  95. def parse_url(url):
  96. """
  97. Minimal parse => scheme://host[:port]/path
  98. scheme= http => default port=80, https => default=443
  99. """
  100. url = url.strip()
  101. scheme = "http"
  102. if url.startswith("http://"):
  103. after = url[7:]
  104. scheme = "http"
  105. elif url.startswith("https://"):
  106. after = url[8:]
  107. scheme = "https"
  108. else:
  109. after = url
  110.  
  111. slash = after.find("/")
  112. if slash == -1:
  113. host_port = after
  114. path = "/"
  115. else:
  116. host_port = after[:slash]
  117. path = after[slash:] or "/"
  118.  
  119. if ":" in host_port:
  120. h, p = host_port.split(":", 1)
  121. port = int(p)
  122. host = h
  123. else:
  124. host = host_port
  125. port = 443 if scheme == "https" else 80
  126.  
  127. return ParsedURL(scheme, host.strip(), port, path)
  128.  
  129. ###############################################################################
  130. # 2) HTTP with chunked decode + manual 3xx
  131. ###############################################################################
  132.  
  133. def http_request(url_obj, method="GET", headers=None, body="", max_redirects=10):
  134. if headers is None:
  135. headers = {}
  136. cur_url = url_obj
  137. cur_method = method
  138. cur_body = body
  139.  
  140. for _ in range(max_redirects):
  141. r_headers, r_body, r_url = _single_http_request(cur_url, cur_method, headers, cur_body)
  142. status_code = int(r_headers.get(":status_code", "0"))
  143. if status_code in (301, 302, 303, 307, 308):
  144. location = r_headers.get("location", "")
  145. if not location:
  146. return r_headers, r_body, r_url
  147. new_url = parse_url(location)
  148. if status_code in (302, 303):
  149. cur_method = "GET"
  150. cur_body = ""
  151. cur_url = new_url
  152. else:
  153. return r_headers, r_body, r_url
  154. return r_headers, r_body, r_url
  155.  
  156. def _single_http_request(url_obj, method="GET", headers=None, body=""):
  157. if url_obj.scheme == "https":
  158. return _requests_https(url_obj, method, headers, body)
  159. else:
  160. return _raw_http(url_obj, method, headers, body)
  161.  
  162. def _requests_https(url_obj, method="GET", headers=None, body=""):
  163. import requests
  164.  
  165. if headers is None:
  166. headers = {}
  167. final_h = {}
  168. for k, v in headers.items():
  169. if k.lower() not in ["host", "content-length"]:
  170. final_h[k] = v
  171.  
  172. if url_obj.port != 443:
  173. full_url = f"https://{url_obj.host}:{url_obj.port}{url_obj.path}"
  174. else:
  175. full_url = f"https://{url_obj.host}{url_obj.path}"
  176.  
  177. resp = requests.request(
  178. method=method,
  179. url=full_url,
  180. headers=final_h,
  181. data=body.encode("utf-8") if body else None,
  182. allow_redirects=False,
  183. verify=False
  184. )
  185.  
  186. r_h = {":status_code": str(resp.status_code)}
  187. for k, v in resp.headers.items():
  188. r_h[k.lower()] = v
  189. return r_h, resp.content, url_obj
  190.  
  191. def _raw_http(url_obj, method="GET", headers=None, body=""):
  192. if headers is None:
  193. headers = {}
  194. ip_addr = resolve_hostname_dns(url_obj.host)
  195. if not ip_addr:
  196. raise Exception(f"DNS fail => {url_obj.host}")
  197.  
  198. import socket
  199. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  200. sock.connect((ip_addr, url_obj.port))
  201.  
  202. lines = [
  203. f"{method} {url_obj.path} HTTP/1.1",
  204. f"Host: {url_obj.host}"
  205. ]
  206. for k, v in headers.items():
  207. if k.lower() != "host":
  208. lines.append(f"{k}: {v}")
  209. lines.append("Connection: close")
  210. lines.append(f"Content-Length: {len(body)}")
  211. lines.append("")
  212. req_str = "\r\n".join(lines) + "\r\n" + body
  213. sock.sendall(req_str.encode("utf-8"))
  214.  
  215. response = b""
  216. while True:
  217. chunk = sock.recv(4096)
  218. if not chunk:
  219. break
  220. response += chunk
  221. sock.close()
  222.  
  223. hd_end = response.find(b"\r\n\r\n")
  224. if hd_end == -1:
  225. return {}, b"", url_obj
  226.  
  227. raw_header = response[:hd_end].decode("utf-8", "replace")
  228. raw_body = response[hd_end+4:]
  229.  
  230. lines = raw_header.split("\r\n")
  231. st_line = lines[0]
  232. parts = st_line.split(" ", 2)
  233. headers_dict = {}
  234. if len(parts) >= 2:
  235. headers_dict[":status_code"] = parts[1]
  236.  
  237. for line in lines[1:]:
  238. if ":" in line:
  239. kk, vv = line.split(":", 1)
  240. headers_dict[kk.strip().lower()] = vv.strip()
  241.  
  242. te = headers_dict.get("transfer-encoding", "").lower()
  243. if "chunked" in te:
  244. raw_body = decode_chunked_body(raw_body)
  245. return headers_dict, raw_body, url_obj
  246.  
  247. def decode_chunked_body(rb):
  248. i = 0
  249. decoded = b""
  250. while True:
  251. newline = rb.find(b"\r\n", i)
  252. if newline == -1:
  253. break
  254. chunk_size_hex = rb[i:newline].decode("utf-8", "replace").strip()
  255. i = newline + 2
  256. try:
  257. chunk_size = int(chunk_size_hex, 16)
  258. except:
  259. chunk_size = 0
  260. if chunk_size == 0:
  261. break
  262. chunk_data = rb[i : i+chunk_size]
  263. decoded += chunk_data
  264. i += chunk_size
  265. if rb[i:i+2] == b"\r\n":
  266. i += 2
  267. return decoded
  268.  
  269. ###############################################################################
  270. # 3) DOM Node + CSS + JS
  271. ###############################################################################
  272.  
  273. class DOMNode:
  274. def __init__(self, tag_name="document", parent=None):
  275. self.tag_name = tag_name.lower()
  276. self.attributes = {}
  277. self.children = []
  278. self.parent = parent
  279. self.text = ""
  280. self.styles = {}
  281. self.computed_styles = {}
  282. self.inline_css = ""
  283. self.script_code = ""
  284. self.is_form = (self.tag_name == "form")
  285. self.method = "get"
  286. self.action = ""
  287. self.form_fields = {}
  288.  
  289. inline_set = {
  290. "text","span","a","b","strong","i","em","u","small","code","mark","img",
  291. "sub","sup","del","ins","abbr","kbd","q","var","s","cite","time"
  292. }
  293. self.is_inline = (self.tag_name in inline_set)
  294.  
  295. self.id = ""
  296. self.classes = []
  297. self.display = "block"
  298. self.margin = {"top":0,"right":0,"bottom":0,"left":0}
  299. self.padding = {"top":0,"right":0,"bottom":0,"left":0}
  300. self.color = ""
  301. self.background_color = ""
  302. self.font_weight = ""
  303. self.font_style = ""
  304. self.text_decoration = ""
  305. self.font_size = ""
  306. self.event_handlers = {}
  307.  
  308. def __repr__(self):
  309. return f"<DOMNode tag='{self.tag_name}' text='{self.text[:20]}...' children={len(self.children)}>"
  310.  
  311. def parse_html(ht):
  312. i = 0
  313. root = DOMNode("document")
  314. current = root
  315. tb = []
  316.  
  317. def flush_text_buffer():
  318. nonlocal tb, current
  319. if not tb:
  320. return
  321. leftover = "".join(tb)
  322. tb = []
  323. leftover = leftover.replace("\r", " ").replace("\n", " ")
  324. leftover = leftover.replace("&nbsp;", " ")
  325. leftover = leftover.replace("&quot;", "\"")
  326. if leftover != "":
  327. n = DOMNode(parent=current)
  328. n.tag_name = "text"
  329. n.text = leftover
  330. current.children.append(n)
  331.  
  332. while i < len(ht):
  333. if ht[i] == "<":
  334. flush_text_buffer()
  335. close_i = ht.find(">", i)
  336. if close_i == -1:
  337. break
  338. tag_c = ht[i+1:close_i].strip()
  339.  
  340. if tag_c.startswith("/"):
  341. close_tag = tag_c[1:].lower()
  342. if current.tag_name == close_tag and current.parent:
  343. current = current.parent
  344. else:
  345. up = current
  346. while up and up.tag_name != close_tag:
  347. up = up.parent
  348. if up and up.parent:
  349. current = up.parent
  350. i = close_i + 1
  351. continue
  352.  
  353. parts = tag_c.split()
  354. if not parts:
  355. i = close_i + 1
  356. continue
  357.  
  358. tname = parts[0].lower()
  359. nd = DOMNode(tname, parent=current)
  360.  
  361. # attributes (simple)
  362. for ap in parts[1:]:
  363. eq = ap.find("=")
  364. if eq != -1:
  365. an = ap[:eq].lower()
  366. av = ap[eq+1:].strip("\"' ")
  367. nd.attributes[an] = av
  368. if an == "id":
  369. nd.id = av
  370. elif an == "class":
  371. nd.classes = av.split()
  372. elif an == "style":
  373. for style_part in av.split(";"):
  374. if ":" in style_part:
  375. prop, val = style_part.split(":", 1)
  376. nd.styles[prop.strip()] = val.strip()
  377. elif an.startswith("on"):
  378. event_type = an[2:]
  379. nd.event_handlers[event_type] = av
  380. elif an=="bgcolor":
  381. nd.styles["background-color"] = av
  382. elif an=="color" and tname=="font":
  383. nd.styles["color"] = av
  384. elif an=="align":
  385. if av in ("left","right","center","justify"):
  386. nd.styles["text-align"] = av
  387. elif an=="valign":
  388. if av in ("top","middle","bottom","baseline"):
  389. nd.styles["vertical-align"] = av
  390.  
  391. if tname == "form":
  392. nd.is_form = True
  393. nd.method = nd.attributes.get("method","get").lower()
  394. nd.action = nd.attributes.get("action","")
  395.  
  396. current.children.append(nd)
  397.  
  398. # Self-closing or special
  399. if tname in ["br","hr","meta","link","img","input"]:
  400. if tname=="input":
  401. nm = nd.attributes.get("name","")
  402. val = nd.attributes.get("value","")
  403. fa = current
  404. while fa and not fa.is_form:
  405. fa = fa.parent
  406. if fa and nm:
  407. fa.form_fields[nm] = [val, nd]
  408. i = close_i + 1
  409. continue
  410.  
  411. if tname=="title":
  412. close_t = ht.find("</title>", close_i+1)
  413. if close_t == -1:
  414. i = len(ht)
  415. continue
  416. cont = ht[close_i+1 : close_t]
  417. cont = cont.replace("\r", " ").replace("\n", " ")
  418. cont = cont.replace("&nbsp;"," ").replace("&quot;","\"")
  419. nd.text = cont
  420. i = close_t + len("</title>")
  421. continue
  422. elif tname=="textarea":
  423. close_t = ht.find("</textarea>", close_i+1)
  424. if close_t == -1:
  425. i = len(ht)
  426. continue
  427. cont = ht[close_i+1 : close_t]
  428. cont = cont.replace("\r", " ").replace("\n", " ")
  429. cont = cont.replace("&nbsp;"," ").replace("&quot;","\"")
  430. nd.text = cont
  431. fa = current
  432. while fa and not fa.is_form:
  433. fa = fa.parent
  434. nm = nd.attributes.get("name","")
  435. if fa and nm:
  436. fa.form_fields[nm] = [cont, nd]
  437. i = close_t + len("</textarea>")
  438. continue
  439. elif tname=="style":
  440. close_t = ht.find("</style>", close_i+1)
  441. if close_t == -1:
  442. i = len(ht)
  443. continue
  444. st = ht[close_i+1 : close_t]
  445. nd.inline_css = st
  446. i = close_t + len("</style>")
  447. continue
  448. elif tname=="script":
  449. close_t = ht.find("</script>", close_i+1)
  450. if close_t == -1:
  451. i = len(ht)
  452. continue
  453. sc = ht[close_i+1 : close_t]
  454. nd.script_code = sc
  455. i = close_t + len("</script>")
  456. continue
  457. else:
  458. current = nd
  459. i = close_i + 1
  460. else:
  461. tb.append(ht[i])
  462. i += 1
  463.  
  464. if tb:
  465. leftover = "".join(tb)
  466. leftover = leftover.replace("\r", " ").replace("\n", " ")
  467. leftover = leftover.replace("&nbsp;", " ").replace("&quot;","\"")
  468. n = DOMNode(parent=current)
  469. n.tag_name = "text"
  470. n.text = leftover
  471. current.children.append(n)
  472. return root
  473.  
  474. class CSSRule:
  475. def __init__(self, selector, properties):
  476. self.selector = selector
  477. self.properties = properties
  478. self.specificity = self._calc_spec(selector)
  479.  
  480. def _calc_spec(self, sel):
  481. idc = sel.count("#")
  482. clc = sel.count(".")
  483. stripped = re.sub(r'[#\.]', ' ', sel)
  484. words = re.findall(r'[a-zA-Z]+', stripped)
  485. elc = len(words)
  486. return (idc, clc, elc)
  487.  
  488. def parse_css(css_text):
  489. rules = []
  490. i = 0
  491. while i < len(css_text):
  492. bo = css_text.find("{", i)
  493. if bo == -1:
  494. break
  495. sel_text = css_text[i:bo].strip()
  496. bc = css_text.find("}", bo)
  497. if bc == -1:
  498. break
  499. block = css_text[bo+1:bc].strip()
  500. i = bc + 1
  501.  
  502. props = {}
  503. for line in block.split(";"):
  504. line = line.strip()
  505. if ":" in line:
  506. p, v = line.split(":", 1)
  507. props[p.strip()] = v.strip()
  508.  
  509. for s in sel_text.split(","):
  510. s = s.strip()
  511. if s:
  512. rules.append(CSSRule(s, props))
  513. return rules
  514.  
  515. def selector_matches(sel, node):
  516. if sel.lower() == node.tag_name.lower():
  517. return True
  518. if sel.startswith("#") and node.id == sel[1:]:
  519. return True
  520. if sel.startswith(".") and sel[1:] in node.classes:
  521. return True
  522. if " " in sel:
  523. parent_sel, child_sel = sel.rsplit(" ", 1)
  524. if selector_matches(child_sel, node):
  525. p = node.parent
  526. while p:
  527. if selector_matches(parent_sel, p):
  528. return True
  529. p = p.parent
  530. return False
  531.  
  532. def apply_css_rules(node, rules):
  533. matched = []
  534. for r in rules:
  535. if selector_matches(r.selector, node):
  536. matched.append(r)
  537. matched.sort(key=lambda x: x.specificity)
  538. for m in matched:
  539. for k, v in m.properties.items():
  540. node.styles[k] = v
  541. for c in node.children:
  542. apply_css_rules(c, rules)
  543.  
  544. def _px_to_int(v, default=0, base_for_percent=None):
  545. try:
  546. s = str(v).strip()
  547. if s.endswith("%") and base_for_percent is not None:
  548. return int(float(s[:-1]) * 0.01 * base_for_percent)
  549. if s.endswith("px"):
  550. return int(float(s[:-2]))
  551. if s.endswith("pt"):
  552. return int(float(s[:-2]) * 1.33)
  553. return int(float(s))
  554. except:
  555. return default
  556.  
  557. def compute_styles(node):
  558. defaults = {
  559. "color": "black",
  560. "background-color": "transparent",
  561. "font-size": "12px",
  562. "font-weight": "normal",
  563. "font-style": "normal",
  564. "text-decoration": "none",
  565. "display": "inline" if node.is_inline else "block",
  566. "margin-top": "0px",
  567. "margin-right": "0px",
  568. "margin-bottom": "0px",
  569. "margin-left": "0px",
  570. "padding-top": "0px",
  571. "padding-right": "0px",
  572. "padding-bottom": "0px",
  573. "padding-left": "0px",
  574. "text-align": "left",
  575. "vertical-align": "top"
  576. }
  577.  
  578. if node.parent and hasattr(node.parent, "computed_styles"):
  579. for p in ["color", "font-size", "font-family"]:
  580. if p in node.parent.computed_styles:
  581. defaults[p] = node.parent.computed_styles[p]
  582.  
  583. for k, v in node.styles.items():
  584. defaults[k] = v
  585.  
  586. t = node.tag_name
  587. if t in ["h1", "h2", "h3", "h4", "h5", "h6"]:
  588. defaults["font-weight"] = "bold"
  589. defaults["font-size"] = {
  590. "h1":"24px","h2":"20px","h3":"18px","h4":"16px","h5":"14px","h6":"13px"
  591. }[t]
  592. if t in ["b", "strong"]:
  593. defaults["font-weight"] = "bold"
  594. if t in ["i", "em"]:
  595. defaults["font-style"] = "italic"
  596. if t == "u":
  597. defaults["text-decoration"] = "underline"
  598. if t == "a" and defaults["color"] == "black":
  599. defaults["text-decoration"] = "underline"
  600. defaults["color"] = "blue"
  601. if t == "th":
  602. defaults["font-weight"] = "bold"
  603. if t == "p":
  604. if _px_to_int(defaults["margin-bottom"]) < 15:
  605. defaults["margin-bottom"] = "15px"
  606.  
  607. node.computed_styles = defaults
  608.  
  609. for c in node.children:
  610. compute_styles(c)
  611.  
  612. class JSEngine:
  613. def __init__(self, dom_root):
  614. self.dom_root = dom_root
  615. self.global_vars = {}
  616.  
  617. def execute_scripts(self):
  618. scripts = self._collect_scripts(self.dom_root)
  619. for sc in scripts:
  620. try:
  621. self._exec(sc)
  622. except Exception as e:
  623. print("JS Error:", e)
  624.  
  625. def _collect_scripts(self, node):
  626. arr = []
  627. if node.tag_name == "script" and node.script_code:
  628. arr.append(node.script_code)
  629. for c in node.children:
  630. arr.extend(self._collect_scripts(c))
  631. return arr
  632.  
  633. def _exec(self, sc):
  634. for line in sc.split(";"):
  635. line=line.strip()
  636. if not line:
  637. continue
  638. if "=" in line and "==" not in line:
  639. var_name, value = line.split("=",1)
  640. var_name = var_name.strip()
  641. value = value.strip()
  642. if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
  643. value = value[1:-1]
  644. elif value.isdigit():
  645. value = int(value)
  646. elif value.replace('.', '', 1).isdigit():
  647. value = float(value)
  648. self.global_vars[var_name] = value
  649. elif line.startswith("alert(") and line.endswith(")"):
  650. c=line[6:-1].strip()
  651. if (c.startswith('"') and c.endswith('"')) or (c.startswith("'") and c.endswith("'")):
  652. c=c[1:-1]
  653. print("ALERT:", c)
  654. elif line.startswith("console.log(") and line.endswith(")"):
  655. c=line[12:-1].strip()
  656. if (c.startswith('"') and c.endswith('"')) or (c.startswith("'") and c.endswith("'")):
  657. c=c[1:-1]
  658. print("CONSOLE:", c)
  659.  
  660. def handle_event(self, node, event_type):
  661. if event_type in node.event_handlers:
  662. code=node.event_handlers[event_type]
  663. try:
  664. self._exec(code)
  665. except Exception as e:
  666. print("JS Event Error:", e)
  667.  
  668. ###############################################################################
  669. # 4) LAYOUT ENGINE (HN-focused upgrades)
  670. ###############################################################################
  671.  
  672. GLOBAL_MEASURE_CANVAS = None
  673.  
  674. class LayoutBox:
  675. def __init__(self, node):
  676. self.dom_node = node
  677. self.x = 0
  678. self.y = 0
  679. self.width = 0
  680. self.height = 0
  681. self.children = []
  682. self.style = {
  683. "bold": False,
  684. "italic": False,
  685. "underline": False,
  686. "color": "black",
  687. "size": 12,
  688. "margin": {"top":0,"right":0,"bottom":0,"left":0},
  689. "padding": {"top":0,"right":0,"bottom":0,"left":0},
  690. "background_color": "transparent",
  691. "text_align": "left",
  692. "valign": "top"
  693. }
  694.  
  695. self.widget = None
  696. self.is_image = False
  697. self.is_input = False
  698. self.is_button = False
  699. self.is_textarea = False
  700.  
  701. self.is_inline = node.is_inline
  702.  
  703. def measure_text(txt, style):
  704. global GLOBAL_MEASURE_CANVAS
  705. if not GLOBAL_MEASURE_CANVAS:
  706. base = style["size"] * 0.6
  707. return max(0, len(txt)) * base
  708. weight = "bold" if style["bold"] else "normal"
  709. slant = "italic" if style["italic"] else "roman"
  710. underline = style["underline"]
  711. size = style["size"]
  712. f = Font(family="Arial", size=size, weight=weight, slant=slant, underline=underline)
  713. return f.measure(txt)
  714.  
  715. def measure_lineheight(style):
  716. global GLOBAL_MEASURE_CANVAS
  717. if not GLOBAL_MEASURE_CANVAS:
  718. return style["size"] + 6
  719. weight = "bold" if style["bold"] else "normal"
  720. slant = "italic" if style["italic"] else "roman"
  721. underline = style["underline"]
  722. size = style["size"]
  723. f = Font(family="Arial", size=size, weight=weight, slant=slant, underline=underline)
  724. return f.metrics("linespace")
  725.  
  726. def combine_styles(parent_style, node):
  727. s = dict(parent_style)
  728.  
  729. cs = node.computed_styles
  730. if cs:
  731. if "color" in cs:
  732. s["color"] = cs["color"]
  733.  
  734. fw = cs.get("font-weight","normal")
  735. if fw in ["bold","bolder"] or (str(fw).isdigit() and int(fw) >= 700):
  736. s["bold"] = True
  737. else:
  738. s["bold"] = (True if parent_style.get("bold") else False)
  739. if cs.get("font-style","normal") == "italic":
  740. s["italic"] = True
  741. else:
  742. s["italic"] = (True if parent_style.get("italic") else False)
  743.  
  744. if "underline" in cs.get("text-decoration",""):
  745. s["underline"] = True
  746. else:
  747. s["underline"] = True if parent_style.get("underline") else False
  748.  
  749. fs = cs.get("font-size","12px")
  750. val = 12
  751. if str(fs).endswith("px"):
  752. try:
  753. val = int(float(fs[:-2]))
  754. except:
  755. pass
  756. elif str(fs).endswith("pt"):
  757. try:
  758. val = int(float(fs[:-2]) * 1.33)
  759. except:
  760. pass
  761. s["size"] = val
  762.  
  763. bc = cs.get("background-color","transparent")
  764. s["background_color"] = bc
  765.  
  766. for side in ["top","right","bottom","left"]:
  767. s["margin"][side] = _px_to_int(cs.get(f"margin-{side}","0px"), 0)
  768. s["padding"][side] = _px_to_int(cs.get(f"padding-{side}","0px"), 0)
  769.  
  770. s["text_align"] = cs.get("text-align", s.get("text_align", "left"))
  771. s["valign"] = cs.get("vertical-align", s.get("valign", "top"))
  772.  
  773. t = node.tag_name
  774. if t in ["b","strong"]:
  775. s["bold"] = True
  776. if t in ["i","em"]:
  777. s["italic"] = True
  778. if t=="u":
  779. s["underline"] = True
  780. if t=="p" and s["margin"]["bottom"]<15:
  781. s["margin"]["bottom"] = 15
  782.  
  783. return s
  784.  
  785. def _node_text_content(node):
  786. if node.tag_name == "text":
  787. return node.text
  788. out = ""
  789. for c in node.children:
  790. out += _node_text_content(c)
  791. return out
  792.  
  793. def layout_tree(dom_node, container_width=800, offset_x=0, offset_y=0):
  794. root_box = LayoutBox(dom_node)
  795.  
  796. def layout_block(node, pbox, x, y, avail_w, parent_st):
  797. box = LayoutBox(node)
  798. pbox.children.append(box)
  799.  
  800. st = combine_styles(parent_st, node)
  801. box.style = st
  802.  
  803. mt = st["margin"]["top"]
  804. ml = st["margin"]["left"]
  805. mr = st["margin"]["right"]
  806. mb = st["margin"]["bottom"]
  807. pt = st["padding"]["top"]
  808. pl = st["padding"]["left"]
  809. pr = st["padding"]["right"]
  810. pb = st["padding"]["bottom"]
  811.  
  812. # special: table width attribute (px or %)
  813. desired_w = None
  814. if node.tag_name == "table":
  815. if "width" in node.attributes:
  816. desired_w = _px_to_int(node.attributes.get("width",""), base_for_percent=avail_w)
  817. # default desired width
  818. if desired_w is None:
  819. desired_w = avail_w
  820.  
  821. content_x = x + ml + pl
  822. content_y = y + mt + pt
  823. content_w = min(avail_w, desired_w) - (pl + pr)
  824. if content_w < 10:
  825. content_w = 10
  826.  
  827. box.x = x
  828. box.y = y
  829.  
  830. # Centering container
  831. if node.tag_name == "center":
  832. # layout children, then center them horizontally
  833. current_y = content_y
  834. max_w = 0
  835. for child in node.children:
  836. cb = layout_block(child, box, content_x, current_y, avail_w, st)
  837. max_w = max(max_w, cb.width)
  838. current_y = cb.y + cb.height
  839. # shift children to center based on widest child
  840. dx = (avail_w - max_w)//2
  841. if dx > 0:
  842. def shift(lb, dx):
  843. lb.x += dx
  844. for ch in lb.children:
  845. shift(ch, dx)
  846. for ch in box.children:
  847. shift(ch, dx)
  848. box.width = avail_w
  849. box.height = (current_y - y) + mb + pb
  850. return box
  851.  
  852. # Table layout
  853. if node.tag_name=="table":
  854. layout_table(node, box, content_x, content_y, min(avail_w, desired_w), st)
  855. bh = 0
  856. for c in box.children:
  857. end = c.y + c.height
  858. if end > bh:
  859. bh = end
  860. # If the table is narrower than available, keep its own width and center within the content area
  861. table_inner_w = min(avail_w, desired_w)
  862. box.width = table_inner_w + ml + mr + pl + pr
  863. box.height = (bh - y) + mb + pb
  864. # If align center via parent <center>, we already handled. Otherwise keep left.
  865. return box
  866.  
  867. # Horizontal rule
  868. if node.tag_name=="hr":
  869. box.x = content_x
  870. box.y = content_y
  871. box.width = content_w
  872. box.height = 2
  873. return box
  874.  
  875. # Image
  876. if node.tag_name=="img":
  877. box.is_image = True
  878. box.x = content_x
  879. box.y = content_y
  880. wv = node.attributes.get("width","200")
  881. hv = node.attributes.get("height","150")
  882. try:
  883. wv = int(wv)
  884. except:
  885. wv = 200
  886. try:
  887. hv = int(hv)
  888. except:
  889. hv = 150
  890. wv = min(wv, content_w)
  891. box.width = wv
  892. box.height = hv
  893. return box
  894.  
  895. # Input
  896. if node.tag_name=="input":
  897. box.is_input = True
  898. t = node.attributes.get("type","text").lower()
  899. if t=="submit":
  900. box.is_button = True
  901. box.is_input = False
  902. box.x = content_x
  903. box.y = content_y
  904. if t in ["text","password","email","search","url"]:
  905. box.width = min(300, content_w)
  906. else:
  907. box.width = min(150, content_w)
  908. box.height = 30 if t!="checkbox" else 25
  909. return box
  910.  
  911. # Textarea
  912. if node.tag_name=="textarea":
  913. box.is_textarea = True
  914. box.x = content_x
  915. box.y = content_y
  916. box.width = min(400, content_w)
  917. box.height = 70
  918. return box
  919.  
  920. # Button
  921. if node.tag_name=="button":
  922. box.is_button = True
  923. box.x = content_x
  924. box.y = content_y
  925. box.width = min(200, content_w)
  926. box.height = 30
  927. return box
  928.  
  929. # <br> - line break
  930. if node.tag_name=="br":
  931. box.x = content_x
  932. box.y = content_y
  933. box.width = 1
  934. box.height = measure_lineheight(st) // 2
  935. return box
  936.  
  937. # Generic block content with word wrapping
  938. current_y = content_y
  939. line_items = []
  940. line_h = 0
  941. line_x = content_x
  942.  
  943. def flush_line():
  944. nonlocal line_items, current_y, line_h, line_x
  945. # horizontal alignment for block text
  946. if line_items and st.get("text_align") in ("center","right"):
  947. line_width = sum(it.width for it in line_items)
  948. extra = content_w - line_width
  949. if extra > 0:
  950. shift = 0
  951. if st["text_align"] == "center":
  952. shift = extra//2
  953. elif st["text_align"] == "right":
  954. shift = extra
  955. for it in line_items:
  956. it.x += shift
  957. for it in line_items:
  958. it.y = current_y
  959. if line_items:
  960. current_y += line_h
  961. else:
  962. current_y += measure_lineheight(st)
  963. line_items.clear()
  964. line_x = content_x
  965. line_h = 0
  966.  
  967. for child in node.children:
  968. if (not child.is_inline) and child.tag_name != "text":
  969. if line_items:
  970. flush_line()
  971. cb = layout_block(child, box, content_x, current_y, content_w, st)
  972. cb.height = max(cb.height, 1)
  973. current_y = cb.y + cb.height
  974. else:
  975. if child.tag_name == "text":
  976. raw = child.text
  977. if not raw:
  978. continue
  979. tokens = re.findall(r'\S+|\s+', raw)
  980. for tok in tokens:
  981. if tok.isspace() and not line_items:
  982. continue
  983. tbox = LayoutBox(DOMNode("text"))
  984. tbox.style = combine_styles(st, child)
  985. tbox.x = line_x
  986. tbox.y = current_y
  987. tw = measure_text(tok, tbox.style)
  988. th = measure_lineheight(tbox.style)
  989. if (line_x + tw) > (content_x + content_w) and not tok.isspace():
  990. flush_line()
  991. if tok.isspace():
  992. continue
  993. tbox.x = line_x
  994. tbox.y = current_y
  995. tw = measure_text(tok, tbox.style)
  996. th = measure_lineheight(tbox.style)
  997. tbox.width = tw
  998. tbox.height = th
  999. tbox.dom_node.text = tok
  1000. box.children.append(tbox)
  1001. line_items.append(tbox)
  1002. line_x += tw
  1003. if th > line_h:
  1004. line_h = th
  1005. else:
  1006. cbox = layout_block(child, box, line_x, current_y, content_w - (line_x - content_x), st)
  1007. if cbox.width == 0 and cbox.dom_node.tag_name != "br":
  1008. txt = _node_text_content(cbox.dom_node)
  1009. cbox.width = measure_text(txt, cbox.style) if txt else 0
  1010. cbox.height = measure_lineheight(cbox.style)
  1011. if line_x + cbox.width > (content_x + content_w) and line_items:
  1012. flush_line()
  1013. cbox.x = line_x
  1014. cbox.y = current_y
  1015. line_items.append(cbox)
  1016. line_x += cbox.width
  1017. if cbox.height > line_h:
  1018. line_h = cbox.height
  1019.  
  1020. if line_items:
  1021. flush_line()
  1022.  
  1023. if current_y == content_y:
  1024. current_y = content_y + measure_lineheight(st)
  1025.  
  1026. box.width = avail_w
  1027. box.height = (current_y - y) + mb + pb
  1028. return box
  1029.  
  1030. def collect_table_rows(table_node):
  1031. rows = []
  1032. rows.extend([c for c in table_node.children if c.tag_name == "tr"])
  1033. for c in table_node.children:
  1034. if c.tag_name in ("tbody","thead","tfoot"):
  1035. rows.extend([r for r in c.children if r.tag_name == "tr"])
  1036. direct_tds = [c for c in table_node.children if c.tag_name in ("td","th")]
  1037. if direct_tds:
  1038. fake_tr = DOMNode("tr", parent=table_node)
  1039. fake_tr.children = direct_tds
  1040. rows.insert(0, fake_tr)
  1041. return rows
  1042.  
  1043. def _explicit_cell_width_px(cell, total_w):
  1044. # width attribute or style width on td
  1045. if "width" in cell.attributes:
  1046. return _px_to_int(cell.attributes["width"], base_for_percent=total_w)
  1047. if "width" in cell.styles:
  1048. return _px_to_int(cell.styles["width"], base_for_percent=total_w)
  1049. # image inside cell (common in HN header: 18x18 logo)
  1050. for ch in cell.children:
  1051. if ch.tag_name == "img":
  1052. w = ch.attributes.get("width", "")
  1053. if w:
  1054. return _px_to_int(w, base_for_percent=total_w)
  1055. return None
  1056.  
  1057. def _cell_min_width_heuristic(cell, total_w, cell_index):
  1058. # Heuristics tailored for HN:
  1059. # - rank column (first .title cell): width of text like "123."
  1060. # - votelinks column: tiny arrow area (~16-18px)
  1061. classes = set(cell.classes)
  1062. if "votelinks" in classes:
  1063. return 18
  1064. if "title" in classes and cell_index == 0:
  1065. txt = _node_text_content(cell).strip() or "99."
  1066. return max(18, _px_to_int(measure_text(txt, {"size":12,"bold":False,"italic":False,"underline":False}), 18))
  1067. return None
  1068.  
  1069. def _colspan(cell):
  1070. try:
  1071. return int(cell.attributes.get("colspan","1"))
  1072. except:
  1073. return 1
  1074.  
  1075. def layout_table(node, pbox, x, y, w, st):
  1076. # table attributes
  1077. cp = _px_to_int(node.attributes.get("cellpadding","0"))
  1078. cs = _px_to_int(node.attributes.get("cellspacing","0"))
  1079. row_nodes = collect_table_rows(node)
  1080.  
  1081. # Determine column count by max colspan sum
  1082. ncols = 0
  1083. for rn in row_nodes:
  1084. s = 0
  1085. for c in rn.children:
  1086. if c.tag_name in ("td","th"):
  1087. s += _colspan(c)
  1088. ncols = max(ncols, s)
  1089. if ncols == 0:
  1090. pbox.width = w
  1091. pbox.height = 0
  1092. return
  1093.  
  1094. # First pass: gather fixed/min widths
  1095. col_fixed = [0]*ncols
  1096. col_min = [0]*ncols
  1097.  
  1098. for rn in row_nodes:
  1099. ci = 0
  1100. for c in rn.children:
  1101. if c.tag_name not in ("td","th"):
  1102. continue
  1103. span = _colspan(c)
  1104. exp = _explicit_cell_width_px(c, w)
  1105. heur = _cell_min_width_heuristic(c, w, ci)
  1106. want = exp if exp is not None else heur
  1107. if want is not None:
  1108. # apply to the first column in the span if span==1, otherwise distribute minimally
  1109. if span == 1:
  1110. col_fixed[ci] = max(col_fixed[ci], want)
  1111. else:
  1112. share = want // span
  1113. for k in range(ci, ci+span):
  1114. col_fixed[k] = max(col_fixed[k], share)
  1115. else:
  1116. # estimate minimal width from a short content sample
  1117. txt = _node_text_content(c).strip()
  1118. if txt:
  1119. tokens = re.findall(r'\S+', txt)
  1120. longest = max(tokens, key=len) if tokens else ""
  1121. mw = int(measure_text(longest, {"size":12,"bold":False,"italic":False,"underline":False})) + 2*cp
  1122. # spread across span
  1123. if span == 1:
  1124. col_min[ci] = max(col_min[ci], mw)
  1125. else:
  1126. share = mw // span
  1127. for k in range(ci, ci+span):
  1128. col_min[k] = max(col_min[k], share)
  1129. ci += span
  1130.  
  1131. # Build initial widths
  1132. col_w = [max(col_fixed[i], col_min[i]) for i in range(ncols)]
  1133. total_spacing = (ncols+1) * cs # simple outer spacing model
  1134. remaining = max(0, w - total_spacing - sum(col_w))
  1135.  
  1136. # Give all remaining width to the last column (HN titles)
  1137. if remaining > 0:
  1138. col_w[-1] += remaining
  1139.  
  1140. # Row layout
  1141. row_y = y
  1142. used_h = 0
  1143. for rn in row_nodes:
  1144. rbox = LayoutBox(rn)
  1145. pbox.children.append(rbox)
  1146. rbox.style = combine_styles(st, rn)
  1147. rbox.x = x
  1148. rbox.y = row_y
  1149.  
  1150. ci = 0
  1151. cx = x + cs
  1152. maxh = 0
  1153. for cnode in rn.children:
  1154. if cnode.tag_name not in ("td","th"):
  1155. continue
  1156. span = _colspan(cnode)
  1157. width_span = sum(col_w[ci:ci+span]) + (span-1)*cs
  1158. # cell background from styles
  1159. cbox = LayoutBox(cnode)
  1160. rbox.children.append(cbox)
  1161. cbox.style = combine_styles(rbox.style, cnode)
  1162. cbox.x = cx
  1163. cbox.y = row_y + cs
  1164. inner_x = cbox.x + cp
  1165. inner_y = cbox.y + cp
  1166. inner_w = max(1, width_span - 2*cp)
  1167. # layout children inside the cell
  1168. # (use generic block layout for each child)
  1169. cy = inner_y
  1170. temp_parent = LayoutBox(DOMNode("cell-content"))
  1171. cbox.children.append(temp_parent)
  1172. # background color draw uses cbox.style in renderer
  1173. for cc in cnode.children:
  1174. cb = layout_block(cc, temp_parent, inner_x, cy, inner_w, cbox.style)
  1175. cy = cb.y + cb.height
  1176. cell_h = max(cy - (row_y + cs) + cp, measure_lineheight(cbox.style) + 2*cp)
  1177. cbox.width = width_span
  1178. cbox.height = cell_h
  1179. # advance column
  1180. cx += width_span + cs
  1181. ci += span
  1182. if cbox.height > maxh:
  1183. maxh = cbox.height
  1184.  
  1185. if ci == 0:
  1186. # empty row (e.g., spacer with style height)
  1187. rh = _px_to_int(rn.styles.get("height","0px"))
  1188. maxh = max(maxh, rh, measure_lineheight(rbox.style)//2)
  1189.  
  1190. rbox.width = w
  1191. rbox.height = maxh + cs
  1192. row_y += rbox.height
  1193. used_h += rbox.height
  1194.  
  1195. pbox.width = w
  1196. pbox.height = used_h
  1197.  
  1198. top_box = layout_block(dom_node, root_box, offset_x, offset_y, container_width, root_box.style)
  1199. return top_box
  1200.  
  1201. def find_box_bottom(lb):
  1202. my = lb.y + lb.height
  1203. for c in lb.children:
  1204. end = find_box_bottom(c)
  1205. if end > my:
  1206. my = end
  1207. return my
  1208.  
  1209. ###############################################################################
  1210. # 5) find_form_ancestor
  1211. ###############################################################################
  1212.  
  1213. def find_form_ancestor(node):
  1214. p = node.parent
  1215. while p and not p.is_form:
  1216. p = p.parent
  1217. return p
  1218.  
  1219. ###############################################################################
  1220. # 6) RENDER
  1221. ###############################################################################
  1222.  
  1223. class LinkArea:
  1224. def __init__(self, x1, y1, x2, y2, href):
  1225. self.x1 = x1
  1226. self.y1 = y1
  1227. self.x2 = x2
  1228. self.y2 = y2
  1229. self.href = href
  1230.  
  1231. def render_layout_box(browser, lb, canvas, widget_list, link_areas):
  1232. st = lb.style
  1233. x = lb.x
  1234. y = lb.y
  1235. w = lb.width
  1236. h = lb.height
  1237. node = lb.dom_node
  1238.  
  1239. bc = st["background_color"]
  1240. if bc and bc != "transparent":
  1241. canvas.create_rectangle(x, y, x+w, y+h, fill=bc, outline="")
  1242.  
  1243. if node.tag_name=="hr":
  1244. canvas.create_line(x, y+1, x+w, y+1, fill="#aaa")
  1245. return
  1246.  
  1247. if lb.is_image:
  1248. src = node.attributes.get("src","")
  1249. if src:
  1250. browser.draw_image(canvas, src, x, y)
  1251. return
  1252.  
  1253. if lb.is_button:
  1254. if node.tag_name=="button":
  1255. label = node.text if node.text else "Submit"
  1256. else:
  1257. label = node.attributes.get("value","Submit")
  1258. b = tk.Button(canvas, text=label, command=lambda: browser.on_button_click(node))
  1259. canvas.create_window(x+5, y+5, anchor="nw", window=b, width=w-10, height=h-10)
  1260. lb.widget = b
  1261. widget_list.append(lb)
  1262. return
  1263.  
  1264. if lb.is_input:
  1265. t = node.attributes.get("type","text").lower()
  1266. if t == "checkbox":
  1267. var = tk.BooleanVar(value=False)
  1268. if "checked" in node.attributes:
  1269. var.set(True)
  1270. nm = node.attributes.get("name","")
  1271. cb = tk.Checkbutton(canvas, text=nm, variable=var)
  1272. cb.var = var
  1273. canvas.create_window(x+5, y+5, anchor="nw", window=cb, width=w-10, height=h-10)
  1274. lb.widget = cb
  1275. widget_list.append(lb)
  1276. return
  1277. else:
  1278. e_var = tk.StringVar()
  1279. nm = node.attributes.get("name","")
  1280. fa = find_form_ancestor(node)
  1281. if fa and nm in fa.form_fields:
  1282. old_val, _ = fa.form_fields[nm]
  1283. e_var.set(old_val)
  1284. e = tk.Entry(canvas, textvariable=e_var)
  1285. canvas.create_window(x+5, y+5, anchor="nw", window=e, width=w-10, height=h-10)
  1286. lb.widget = e
  1287. widget_list.append(lb)
  1288. return
  1289.  
  1290. if lb.is_textarea:
  1291. txt = tk.Text(canvas, width=40, height=4)
  1292. nm = node.attributes.get("name","")
  1293. fa = find_form_ancestor(node)
  1294. old_val = ""
  1295. if fa and nm in fa.form_fields:
  1296. old_val, _ = fa.form_fields[nm]
  1297. if node.text and not old_val:
  1298. old_val = node.text
  1299. txt.insert("1.0", old_val)
  1300. canvas.create_window(x+5, y+5, anchor="nw", window=txt, width=w-10, height=h-10)
  1301. lb.widget = txt
  1302. widget_list.append(lb)
  1303. return
  1304.  
  1305. # text
  1306. wght = "bold" if st["bold"] else "normal"
  1307. slnt = "italic" if st["italic"] else "roman"
  1308. und = 1 if st["underline"] else 0
  1309. color = st["color"]
  1310. f = Font(family="Arial", size=st["size"], weight=wght, slant=slnt, underline=und)
  1311.  
  1312. if node.tag_name=="a":
  1313. href = node.attributes.get("href","")
  1314. child_coords = []
  1315. for c in lb.children:
  1316. render_layout_box(browser, c, canvas, widget_list, link_areas)
  1317. cx1 = c.x
  1318. cy1 = c.y
  1319. cx2 = c.x + c.width
  1320. cy2 = c.y + c.height
  1321. child_coords.append((cx1, cy1, cx2, cy2))
  1322. if child_coords and href:
  1323. minx = min(coord[0] for coord in child_coords)
  1324. miny = min(coord[1] for coord in child_coords)
  1325. maxx = max(coord[2] for coord in child_coords)
  1326. maxy = max(coord[3] for coord in child_coords)
  1327. link_areas.append(LinkArea(minx, miny, maxx, maxy, href))
  1328. else:
  1329. if node.tag_name == "text" and node.text:
  1330. # horizontal alignment inside a cell line is already handled in layout
  1331. canvas.create_text(x+5, y+5, anchor="nw", text=node.text, fill=color, font=f)
  1332.  
  1333. for c in lb.children:
  1334. render_layout_box(browser, c, canvas, widget_list, link_areas)
  1335.  
  1336. ###############################################################################
  1337. # 7) Modern Button
  1338. ###############################################################################
  1339.  
  1340. class ModernButton(tk.Canvas):
  1341. def __init__(self, parent, text, command=None, width=80, height=30,
  1342. bg_color="#2c3e50", hover_color="#3498db",
  1343. text_color="white", font=("Arial",10,"bold"),
  1344. corner_radius=10, **kwargs):
  1345. super().__init__(parent, width=width, height=height,
  1346. highlightthickness=0, bg=parent["bg"], **kwargs)
  1347. self.command = command
  1348. self.bg_color = bg_color
  1349. self.hover_color = hover_color
  1350. self.text_color = text_color
  1351. self.corner_radius = corner_radius
  1352. self.width = width
  1353. self.height = height
  1354. self.text = text
  1355. self.font = font
  1356. self._draw_button(bg_color)
  1357. self.bind("<Enter>", self._on_enter)
  1358. self.bind("<Leave>", self._on_leave)
  1359. self.bind("<Button-1>", self._on_click)
  1360.  
  1361. def _draw_button(self, color):
  1362. self.delete("all")
  1363. for i in range(self.height):
  1364. ratio = i / self.height
  1365. r1,g1,b1 = self._hex_to_rgb(color)
  1366. r2,g2,b2 = self._hex_to_rgb(self._darken_color(color))
  1367. r = int(r1 + (r2-r1)*ratio)
  1368. g = int(g1 + (g2-g1)*ratio)
  1369. b = int(b1 + (b2-b1)*ratio)
  1370. gradient_color = f"#{r:02x}{g:02x}{b:02x}"
  1371. self.create_line(0, i, self.width, i, fill=gradient_color)
  1372. self.create_rectangle(2, 2, self.width-2, self.height-2,
  1373. outline=self._lighten_color(color), width=1)
  1374. self.create_text(self.width//2, self.height//2,
  1375. text=self.text, fill=self.text_color, font=self.font)
  1376.  
  1377. def _hex_to_rgb(self, hc):
  1378. hc = hc.lstrip("#")
  1379. return tuple(int(hc[i:i+2],16) for i in (0,2,4))
  1380.  
  1381. def _darken_color(self, hc, factor=0.8):
  1382. r,g,b = self._hex_to_rgb(hc)
  1383. r = max(0,int(r*factor))
  1384. g = max(0,int(g*factor))
  1385. b = max(0,int(b*factor))
  1386. return f"#{r:02x}{g:02x}{b:02x}"
  1387.  
  1388. def _lighten_color(self, hc, factor=1.2):
  1389. r,g,b = self._hex_to_rgb(hc)
  1390. r = min(255,int(r*factor))
  1391. g = min(255,int(g*factor))
  1392. b = min(255,int(b*factor))
  1393. return f"#{r:02x}{g:02x}{b:02x}"
  1394.  
  1395. def _on_enter(self, event):
  1396. self._draw_button(self.hover_color)
  1397.  
  1398. def _on_leave(self, event):
  1399. self._draw_button(self.bg_color)
  1400.  
  1401. def _on_click(self, event):
  1402. if self.command:
  1403. self.command()
  1404.  
  1405. ###############################################################################
  1406. # 8) BROWSER
  1407. ###############################################################################
  1408.  
  1409. class ToyBrowser:
  1410. def __init__(self):
  1411. self.root = tk.Tk()
  1412. self.root.title("Refactored Web Browser - Clickable Links Preserved")
  1413. self.root.geometry("1000x800")
  1414. bg = "#f0f2f5"
  1415. self.root.configure(bg=bg)
  1416.  
  1417. global GLOBAL_MEASURE_CANVAS
  1418. GLOBAL_MEASURE_CANVAS = tk.Canvas(self.root)
  1419. GLOBAL_MEASURE_CANVAS.pack_forget()
  1420.  
  1421. self.history = []
  1422. self.hist_pos = -1
  1423.  
  1424. top_frame = tk.Frame(self.root, bg="#2c3e50", pady=5, padx=10)
  1425. top_frame.pack(side=tk.TOP, fill=tk.X)
  1426.  
  1427. self.back_btn = ModernButton(top_frame, "◀", self.go_back, 40, 30,
  1428. bg_color="#34495e", hover_color="#3498db")
  1429. self.back_btn.pack(side=tk.LEFT, padx=5)
  1430. self.fwd_btn = ModernButton(top_frame, "▶", self.go_fwd, 40, 30,
  1431. bg_color="#34495e", hover_color="#3498db")
  1432. self.fwd_btn.pack(side=tk.LEFT, padx=5)
  1433.  
  1434. url_frame = tk.Frame(top_frame, bg="#34495e", padx=2, pady=2,
  1435. highlightthickness=1, highlightbackground="#1abc9c")
  1436. url_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=10)
  1437.  
  1438. self.url_bar = tk.Entry(url_frame, font=("Arial",12),
  1439. bg="#ecf0f1", fg="#2c3e50", bd=0,
  1440. insertbackground="#2c3e50")
  1441. self.url_bar.pack(fill=tk.BOTH, expand=True, ipady=3)
  1442. self.url_bar.bind("<Return>", self.on_url_enter)
  1443.  
  1444. self.go_btn = ModernButton(top_frame, "Go", self.on_go_click, 50, 30,
  1445. bg_color="#2ecc71", hover_color="#27ae60")
  1446. self.go_btn.pack(side=tk.LEFT, padx=5)
  1447.  
  1448. bm_frame = tk.Frame(self.root, bg="#ecf0f1", padx=10, pady=5)
  1449. bm_frame.pack(side=tk.TOP, fill=tk.X)
  1450.  
  1451. self.bm1 = ModernButton(bm_frame, "This is a web page", self.bookmark_page1,
  1452. 200, 30, bg_color="#e74c3c", hover_color="#c0392b")
  1453. self.bm1.pack(side=tk.LEFT, padx=10)
  1454. self.bm2 = ModernButton(bm_frame, "Founder's forum", self.bookmark_page2,
  1455. 200, 30, bg_color="#9b59b6", hover_color="#8e44ad")
  1456. self.bm2.pack(side=tk.LEFT, padx=10)
  1457. self.bm3 = ModernButton(bm_frame, "Hacker News", self.bookmark_page3,
  1458. 200, 30, bg_color="#f39c12", hover_color="#d35400")
  1459. self.bm3.pack(side=tk.LEFT, padx=10)
  1460.  
  1461. self.frame = tk.Frame(self.root, bg=bg)
  1462. self.frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=10)
  1463.  
  1464. canvas_frame = tk.Frame(self.frame, bg="white", bd=1, relief=tk.RAISED,
  1465. highlightthickness=1, highlightbackground="#bdc3c7")
  1466. canvas_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  1467.  
  1468. self.canvas = tk.Canvas(canvas_frame, bg="white",
  1469. scrollregion=(0,0,3000,5000),
  1470. highlightthickness=0)
  1471. self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  1472.  
  1473. style = ttk.Style()
  1474. style.theme_use("default")
  1475. style.configure("Vertical.TScrollbar",
  1476. background="#3498db", arrowcolor="white",
  1477. bordercolor="#2980b9", troughcolor="#ecf0f1")
  1478.  
  1479. self.scroll = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL,
  1480. command=self.canvas.yview,
  1481. style="Vertical.TScrollbar")
  1482. self.scroll.pack(side=tk.RIGHT, fill=tk.Y)
  1483. self.canvas.config(yscrollcommand=self.scroll.set)
  1484.  
  1485. self.canvas.bind("<MouseWheel>", self.on_mousewheel_win)
  1486. self.canvas.bind("<Button-4>", self.on_mousewheel_lin)
  1487. self.canvas.bind("<Button-5>", self.on_mousewheel_lin)
  1488. self.canvas.bind("<Button-1>", self.on_canvas_click)
  1489.  
  1490. # Extra mouse buttons for back & forward (Linux style).
  1491. self.canvas.bind("<Button-4>", self.on_mouse_back_forward)
  1492. self.canvas.bind("<Button-5>", self.on_mouse_back_forward)
  1493.  
  1494. self.status_bar = tk.Label(self.root, text="Ready",
  1495. bd=1, relief=tk.SUNKEN, anchor=tk.W,
  1496. bg="#34495e", fg="white", font=("Arial",9))
  1497. self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
  1498.  
  1499. self.images_cache = []
  1500. self.current_dom = None
  1501. self.layout_root = None
  1502. self.current_url_obj = None
  1503. self.form_widgets = []
  1504. self.link_areas = []
  1505. self.css_rules = []
  1506. self.js_engine = None
  1507.  
  1508. def on_mouse_back_forward(self, event):
  1509. if event.num == 4:
  1510. self.go_back()
  1511. elif event.num == 5:
  1512. self.go_fwd()
  1513.  
  1514. def on_url_enter(self, evt):
  1515. self.on_go_click()
  1516.  
  1517. def bookmark_page1(self):
  1518. self.url_bar.delete(0, tk.END)
  1519. self.url_bar.insert(0, "https://justinjackson.ca/words.html")
  1520. self.on_go_click()
  1521.  
  1522. def bookmark_page2(self):
  1523. self.url_bar.delete(0, tk.END)
  1524. self.url_bar.insert(0, "http://162.208.9.114:8081/")
  1525. self.on_go_click()
  1526.  
  1527. def bookmark_page3(self):
  1528. self.url_bar.delete(0, tk.END)
  1529. self.url_bar.insert(0, "https://news.ycombinator.com")
  1530. self.on_go_click()
  1531.  
  1532. def go_back(self):
  1533. if self.hist_pos > 0:
  1534. self.hist_pos -= 1
  1535. url_s = self.history[self.hist_pos]
  1536. self.load_url_str(url_s, push_hist=False)
  1537.  
  1538. def go_fwd(self):
  1539. if self.hist_pos < len(self.history) - 1:
  1540. self.hist_pos += 1
  1541. url_s = self.history[self.hist_pos]
  1542. self.load_url_str(url_s, push_hist=False)
  1543.  
  1544. def on_go_click(self):
  1545. raw = self.url_bar.get().strip()
  1546. self.load_url_str(raw, True)
  1547.  
  1548. def load_url_str(self, url_s, push_hist=True):
  1549. try:
  1550. self.status_bar.config(text=f"Loading {url_s}...")
  1551. self.root.update()
  1552. purl = parse_url(url_s)
  1553. except Exception as e:
  1554. self.show_error(str(e))
  1555. return
  1556. self.load_url(purl, "GET", "", {})
  1557. if push_hist:
  1558. self.history = self.history[:self.hist_pos+1]
  1559. self.history.append(url_s)
  1560. self.hist_pos += 1
  1561.  
  1562. def load_url(self, url_obj, method="GET", body="", extra_headers=None):
  1563. if extra_headers is None:
  1564. extra_headers = {}
  1565. try:
  1566. rh, rb, fu = http_request(url_obj, method, extra_headers, body)
  1567. except Exception as e:
  1568. self.show_error(str(e))
  1569. return
  1570.  
  1571. if (fu.scheme=="http" and fu.port==80) or (fu.scheme=="https" and fu.port==443):
  1572. final_url = f"{fu.scheme}://{fu.host}{fu.path}"
  1573. else:
  1574. final_url = f"{fu.scheme}://{fu.host}:{fu.port}{fu.path}"
  1575. self.url_bar.delete(0, tk.END)
  1576. self.url_bar.insert(0, final_url)
  1577. self.current_url_obj = fu
  1578.  
  1579. ctype = rh.get("content-type","").lower()
  1580. encoding = "utf-8"
  1581. if "charset=" in ctype:
  1582. encp = ctype.split("charset=")[-1].split(";")[0].strip()
  1583. encoding = encp
  1584. try:
  1585. text_data = rb.decode(encoding, "replace")
  1586. except:
  1587. text_data = rb.decode("utf-8", "replace")
  1588.  
  1589. if "text/html" in ctype:
  1590. try:
  1591. self.status_bar.config(text="Parsing HTML...")
  1592. self.root.update()
  1593. dom = parse_html(text_data)
  1594. self.current_dom = dom
  1595. self.canvas.delete("all")
  1596. self.form_widgets.clear()
  1597. self.link_areas.clear()
  1598. self.images_cache.clear()
  1599. self.css_rules = []
  1600.  
  1601. self.status_bar.config(text="Processing CSS...")
  1602. self.root.update()
  1603. cst = self.collect_css(dom, fu)
  1604. cst += self.collect_inline(dom)
  1605. self.css_rules = parse_css(cst)
  1606. apply_css_rules(dom, self.css_rules)
  1607. compute_styles(dom)
  1608.  
  1609. self.status_bar.config(text="Building layout...")
  1610. self.root.update()
  1611. self.layout_root = layout_tree(dom, 800, 0, 0)
  1612. final_y = find_box_bottom(self.layout_root)
  1613. self.canvas.config(scrollregion=(0,0,900,final_y+50))
  1614.  
  1615. self.status_bar.config(text="Rendering page...")
  1616. self.root.update()
  1617. render_layout_box(self, self.layout_root, self.canvas,
  1618. self.form_widgets, self.link_areas)
  1619. self.canvas.yview_moveto(0.0)
  1620.  
  1621. t = self.find_title(dom)
  1622. if t and t.text.strip():
  1623. self.root.title(t.text.strip() + " - Browser")
  1624.  
  1625. self.status_bar.config(text="Running JS...")
  1626. self.root.update()
  1627. self.js_engine = JSEngine(dom)
  1628. self.js_engine.execute_scripts()
  1629.  
  1630. self.status_bar.config(text="Page loaded successfully")
  1631. except Exception as e:
  1632. self.show_error(f"Error rendering page: {e}\n{traceback.format_exc()}")
  1633. else:
  1634. self.canvas.delete("all")
  1635. self.canvas.create_text(10, 10, anchor="nw", text=text_data,
  1636. fill="black", font=("Arial",12))
  1637. self.status_bar.config(text="Plain text content displayed")
  1638.  
  1639. def collect_css(self, node, baseurl):
  1640. s = ""
  1641. if node.tag_name=="link":
  1642. if node.attributes.get("rel","").lower()=="stylesheet":
  1643. href = node.attributes.get("href","").strip()
  1644. if href:
  1645. try:
  1646. if not href.startswith(("http://","https://")):
  1647. if href.startswith("/"):
  1648. href = f"{baseurl.scheme}://{baseurl.host}:{baseurl.port}{href}"
  1649. else:
  1650. p = baseurl.path
  1651. slash = p.rfind("/")
  1652. if slash>0:
  1653. base_dir = p[:slash+1]
  1654. else:
  1655. base_dir = "/"
  1656. href = f"{baseurl.scheme}://{baseurl.host}:{baseurl.port}{base_dir}{href}"
  1657. self.status_bar.config(text=f"Loading CSS => {href}")
  1658. self.root.update()
  1659. cu = parse_url(href)
  1660. hh, bb, _ = http_request(cu, "GET")
  1661. cst = bb.decode("utf-8", "replace")
  1662. s += cst + "\n"
  1663. except Exception as e:
  1664. print("CSS load error:", e)
  1665. for c in node.children:
  1666. s += self.collect_css(c, baseurl)
  1667. return s
  1668.  
  1669. def collect_inline(self, node):
  1670. c = node.inline_css
  1671. for ch in node.children:
  1672. c += "\n" + self.collect_inline(ch)
  1673. return c
  1674.  
  1675. def find_title(self, node):
  1676. if node.tag_name=="title":
  1677. return node
  1678. for c in node.children:
  1679. ans = self.find_title(c)
  1680. if ans:
  1681. return ans
  1682. return None
  1683.  
  1684. def on_button_click(self, node):
  1685. if self.js_engine and "click" in node.event_handlers:
  1686. self.js_engine.handle_event(node, "click")
  1687. fa = find_form_ancestor(node)
  1688. if fa:
  1689. self.submit_form(fa)
  1690.  
  1691. def submit_form(self, form_node):
  1692. m = form_node.method.upper()
  1693. act = form_node.action.strip()
  1694. if act.startswith("/"):
  1695. if self.current_url_obj:
  1696. s = self.current_url_obj.scheme
  1697. h = self.current_url_obj.host
  1698. p = self.current_url_obj.port
  1699. act = f"{s}://{h}:{p}{act}"
  1700. if not act:
  1701. if self.current_url_obj:
  1702. s = self.current_url_obj.scheme
  1703. h = self.current_url_obj.host
  1704. p = self.current_url_obj.port
  1705. path = self.current_url_obj.path
  1706. act = f"{s}://{h}:{p}{path}"
  1707. else:
  1708. act = "http://127.0.0.1/"
  1709. arr = []
  1710. for nm, (ov, nref) in form_node.form_fields.items():
  1711. lb = self.find_layout_box_for_node(self.layout_root, nref)
  1712. tv = ov
  1713. if lb and lb.widget:
  1714. t = nref.attributes.get("type","").lower()
  1715. if t=="checkbox":
  1716. c = lb.widget.var.get()
  1717. if c:
  1718. vv = nref.attributes.get("value","on")
  1719. tv = vv
  1720. else:
  1721. continue
  1722. else:
  1723. import tkinter
  1724. if isinstance(lb.widget, tkinter.Entry):
  1725. tv = lb.widget.get()
  1726. elif isinstance(lb.widget, tkinter.Text):
  1727. tv = lb.widget.get("1.0","end-1c")
  1728. encn = urllib.parse.quote_plus(nm)
  1729. encv = urllib.parse.quote_plus(tv)
  1730. arr.append(f"{encn}={encv}")
  1731. qstr = "&".join(arr)
  1732. if m=="GET":
  1733. if "?" in act:
  1734. finalurl = act + "&" + qstr
  1735. else:
  1736. finalurl = act + "?" + qstr
  1737. self.load_url_str(finalurl)
  1738. else:
  1739. p = parse_url(act)
  1740. hh = {"Content-Type":"application/x-www-form-urlencoded"}
  1741. self.load_url(p, "POST", qstr, hh)
  1742.  
  1743. def find_layout_box_for_node(self, lb, node):
  1744. if lb.dom_node is node:
  1745. return lb
  1746. for c in lb.children:
  1747. ans = self.find_layout_box_for_node(c, node)
  1748. if ans:
  1749. return ans
  1750. return None
  1751.  
  1752. def draw_image(self, canvas, src, x, y):
  1753. """
  1754. Threaded image fetch so the UI doesn't freeze.
  1755. SVG fallback: draw a tiny placeholder (PIL can't parse SVG).
  1756. """
  1757. if not src:
  1758. return
  1759.  
  1760. def announce_download():
  1761. self.status_bar.config(text=f"Loading image => {src}")
  1762.  
  1763. self.root.after(0, announce_download)
  1764.  
  1765. def worker():
  1766. absu = self.make_absolute_url(src)
  1767. try:
  1768. # simple SVG check
  1769. if absu.path.lower().endswith(".svg"):
  1770. raise Exception("SVG not supported by PIL")
  1771. hh, bb, _ = http_request(absu, "GET")
  1772. import io
  1773. im = Image.open(io.BytesIO(bb))
  1774. tkimg = ImageTk.PhotoImage(im)
  1775.  
  1776. def do_main():
  1777. self.images_cache.append(tkimg)
  1778. canvas.create_image(x+5, y+5, anchor="nw", image=tkimg)
  1779. self.status_bar.config(text="Done loading image.")
  1780.  
  1781. self.root.after(0, do_main)
  1782.  
  1783. except Exception:
  1784. def do_err():
  1785. # placeholder keeps layout stable (e.g., HN logo 18x18)
  1786. canvas.create_rectangle(x+5, y+5, x+23, y+23,
  1787. fill="#ffffff", outline="#ffffff")
  1788. self.root.after(0, do_err)
  1789.  
  1790. t = threading.Thread(target=worker, daemon=True)
  1791. t.start()
  1792.  
  1793. def make_absolute_url(self, raw):
  1794. if raw.startswith("http://") or raw.startswith("https://"):
  1795. return parse_url(raw)
  1796. if not self.current_url_obj:
  1797. return parse_url(raw)
  1798. if raw.startswith("//"):
  1799. return ParsedURL(self.current_url_obj.scheme,
  1800. raw.split("//",1)[1].split("/")[0],
  1801. 443 if self.current_url_obj.scheme=="https" else 80,
  1802. "/" + raw.split("//",1)[1].split("/",1)[1] if "/" in raw.split("//",1)[1] else "/")
  1803. if raw.startswith("/"):
  1804. return ParsedURL(self.current_url_obj.scheme,
  1805. self.current_url_obj.host,
  1806. self.current_url_obj.port,
  1807. raw)
  1808. bp = self.current_url_obj.path
  1809. slash = bp.rfind("/")
  1810. base_dir = bp[:slash] if slash != -1 else ""
  1811. newp = base_dir + "/" + raw
  1812. return ParsedURL(self.current_url_obj.scheme,
  1813. self.current_url_obj.host,
  1814. self.current_url_obj.port,
  1815. newp)
  1816.  
  1817. def on_canvas_click(self, event):
  1818. cx = self.canvas.canvasx(event.x)
  1819. cy = self.canvas.canvasy(event.y)
  1820. for la in self.link_areas:
  1821. if la.x1 <= cx <= la.x2 and la.y1 <= cy <= la.y2:
  1822. absu = self.make_absolute_url(la.href)
  1823. final_s = self.url_to_string(absu)
  1824. self.load_url_str(final_s)
  1825. break
  1826.  
  1827. def url_to_string(self, p):
  1828. if (p.scheme=="http" and p.port==80) or (p.scheme=="https" and p.port==443):
  1829. return f"{p.scheme}://{p.host}{p.path}"
  1830. else:
  1831. return f"{p.scheme}://{p.host}:{p.port}{p.path}"
  1832.  
  1833. def on_mousewheel_win(self, event):
  1834. self.canvas.yview_scroll(int(-1*(event.delta/120)), "units")
  1835.  
  1836. def on_mousewheel_lin(self, event):
  1837. if event.num==4:
  1838. self.canvas.yview_scroll(-1, "units")
  1839. else:
  1840. self.canvas.yview_scroll(1, "units")
  1841.  
  1842. def show_error(self, msg):
  1843. self.canvas.delete("all")
  1844. self.link_areas.clear()
  1845. self.images_cache.clear()
  1846. ewidth = 600
  1847. eheight = 300
  1848. x0 = (int(self.canvas.cget("width")) - ewidth) // 2
  1849. y0 = 100
  1850. for i in range(eheight):
  1851. ratio = i / eheight
  1852. r1,g1,b1 = 255,240,240
  1853. r2,g2,b2 = 220,53,69
  1854. r = int(r1 + (r2-r1)*ratio)
  1855. g = int(g1 + (g2-g1)*ratio)
  1856. b = int(b1 + (b2-b1)*ratio)
  1857. c = f"#{r:02x}{g:02x}{b:02x}"
  1858. self.canvas.create_line(x0, y0+i, x0+ewidth, y0+i, fill=c)
  1859. self.canvas.create_rectangle(x0, y0, x0+ewidth, y0+eheight,
  1860. outline="#721c24", width=2)
  1861. icon = 40
  1862. self.canvas.create_oval(x0+30, y0+30, x0+30+icon, y0+30+icon,
  1863. fill="#dc3545", outline="#721c24")
  1864. self.canvas.create_text(x0+30+icon//2, y0+30+icon//2, text="!",
  1865. font=("Arial",24,"bold"), fill="white")
  1866. self.canvas.create_text(x0+100, y0+40, text="Error Loading Page",
  1867. font=("Arial",16,"bold"), fill="#721c24",
  1868. anchor="nw")
  1869. self.canvas.create_text(x0+30, y0+100, text=msg,
  1870. font=("Arial",12), fill="#721c24",
  1871. anchor="nw", width=ewidth-60)
  1872. self.status_bar.config(text=f"Error: {msg}")
  1873.  
  1874. def run(self):
  1875. self.root.mainloop()
  1876.  
  1877. ###############################################################################
  1878. # main
  1879. ###############################################################################
  1880.  
  1881. if __name__=="__main__":
  1882. sys.setrecursionlimit(10**6)
  1883. app = ToyBrowser()
  1884. app.run()
  1885.  
Advertisement
Add Comment
Please, Sign In to add comment