catengine 0.5.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ ---
2
+
3
+ #### `LICENSE`
4
+ ```text
5
+ MIT License
6
+
7
+ Copyright (c) 2026 Catdows Team
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: catengine
3
+ Version: 0.5.7
4
+ Summary: A lightweight PySide6 HTML/CSS browser rendering engine widget.
5
+ Author-email: PurrEmium Team <your-email@example.com>
6
+ Project-URL: Homepage, https://github.com/yourusername/catengine
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: User Interfaces
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.txt
16
+ Requires-Dist: PySide6>=6.0.0
17
+ Dynamic: license-file
18
+
19
+ # CatEngine 🐱
20
+
21
+ A lightweight, custom PySide6 HTML/CSS rendering engine widget built on `QPainter` and `QJSEngine`.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install catengine
@@ -0,0 +1,8 @@
1
+ # CatEngine 🐱
2
+
3
+ A lightweight, custom PySide6 HTML/CSS rendering engine widget built on `QPainter` and `QJSEngine`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install catengine
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "catengine"
7
+ version = "0.5.7"
8
+ authors = [
9
+ { name="PurrEmium Team", email="your-email@example.com" },
10
+ ]
11
+ description = "A lightweight PySide6 HTML/CSS browser rendering engine widget."
12
+ readme = "README.md"
13
+ requires-python = ">=3.8"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Software Development :: User Interfaces",
21
+ ]
22
+ dependencies = [
23
+ "PySide6>=6.0.0"
24
+ ]
25
+
26
+ [project.urls]
27
+ "Homepage" = "https://github.com/yourusername/catengine"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .engine import CatEngine, Node, Parser
2
+
3
+ __version__ = "0.5.7"
4
+ __all__ = ["CatEngine", "Node", "Parser"]
@@ -0,0 +1,766 @@
1
+ """CatEngine Library — A lightweight HTML/CSS rendering and DOM engine widget
2
+ built on PySide6 and QPainter.
3
+ """
4
+ import sys, re, json, urllib.request, urllib.error, threading
5
+ from pathlib import Path
6
+ from html.parser import HTMLParser
7
+ from urllib.parse import urlparse, unquote, urljoin, quote_plus, urlencode, parse_qs
8
+
9
+ from PySide6.QtCore import Qt, QRectF, QSize, Signal, QObject, Slot, QTimer, QRunnable, QThreadPool
10
+ from PySide6.QtGui import QPainter, QColor, QFont, QFontMetrics, QPen, QPixmap, QKeyEvent
11
+ from PySide6.QtWidgets import QWidget
12
+
13
+ HAS_JS = False
14
+ QJSEngine = None
15
+ try:
16
+ from PySide6.QtQml import QJSEngine, QJSValue
17
+ HAS_JS = True
18
+ except Exception:
19
+ QJSValue = None
20
+
21
+ VOID = {"br", "hr", "img", "input", "meta", "link", "area", "base", "embed", "source", "wbr"}
22
+ BLOCK = {"html", "body", "div", "section", "article", "header", "footer", "main", "nav", "aside", "figure", "figcaption", "h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", "blockquote", "pre", "table", "tr", "td", "th", "form"}
23
+ SKIP = {"head", "title", "style", "meta", "script", "link"}
24
+ DEFAULT = {
25
+ "body": {"margin":"16px", "font-size":"16px", "font-family":"Segoe UI, Arial", "color":"#202124", "background":"white"},
26
+ "h1":{"font-size":"32px","font-weight":"bold","margin":"16px 0 12px"},
27
+ "h2":{"font-size":"25px","font-weight":"bold","margin":"14px 0 10px"},
28
+ "h3":{"font-size":"21px","font-weight":"bold","margin":"12px 0 8px"},
29
+ "p":{"margin":"0 0 12px"}, "a":{"color":"#0759c7","text-decoration":"underline"},
30
+ "ul":{"padding-left":"22px","margin":"0 0 12px"}, "ol":{"padding-left":"22px","margin":"0 0 12px"},
31
+ "li":{"margin":"0 0 4px"}, "pre":{"background":"#f1f3f4","padding":"8px","font-family":"monospace"},
32
+ "code":{"background":"#f1f3f4","font-family":"monospace"},
33
+ "button":{"padding":"6px 10px","background":"#eeeeee","border":"1px solid #888"},
34
+ "input":{"padding":"6px 8px","background":"#ffffff","border":"1px solid #767676","width":"200px"}
35
+ }
36
+
37
+ class Node:
38
+ def __init__(self, tag="document", attrs=None, parent=None, text=""):
39
+ self.tag = tag
40
+ self.attrs = dict(attrs or [])
41
+ self.parent = parent
42
+ self.text = text
43
+ self.children = []
44
+ if parent is not None:
45
+ parent.children.append(self)
46
+
47
+ def text_content(self):
48
+ if self.tag == "#text":
49
+ return self.text
50
+ return "".join(c.text_content() for c in self.children)
51
+
52
+ class Parser(HTMLParser):
53
+ def __init__(self):
54
+ super().__init__(convert_charrefs=True)
55
+ self.root = Node()
56
+ self.stack = [self.root]
57
+ self.css, self.scripts, self.links = [], [], []
58
+ self.mode = ""
59
+
60
+ def handle_starttag(self, tag, attrs):
61
+ tag = tag.lower(); ad = dict(attrs)
62
+ n = Node(tag, attrs, self.stack[-1])
63
+ if tag == "style": self.mode = "style"; self.css.append("")
64
+ elif tag == "script": self.mode = "script"; self.scripts.append("")
65
+ elif tag == "link" and ad.get("rel", "").lower() == "stylesheet" and ad.get("href"):
66
+ self.links.append(ad["href"])
67
+ if tag not in VOID: self.stack.append(n)
68
+
69
+ def handle_startendtag(self, tag, attrs):
70
+ tag = tag.lower(); ad = dict(attrs)
71
+ Node(tag, attrs, self.stack[-1])
72
+ if tag == "link" and ad.get("rel", "").lower() == "stylesheet" and ad.get("href"):
73
+ self.links.append(ad["href"])
74
+
75
+ def handle_endtag(self, tag):
76
+ tag = tag.lower()
77
+ if tag in ("style", "script"): self.mode = ""
78
+ for i in range(len(self.stack)-1, 0, -1):
79
+ if self.stack[i].tag == tag:
80
+ self.stack = self.stack[:i]
81
+ break
82
+
83
+ def handle_data(self, data):
84
+ if self.mode == "style":
85
+ if self.css: self.css[-1] += data
86
+ elif self.mode == "script":
87
+ if self.scripts: self.scripts[-1] += data
88
+ elif data:
89
+ Node("#text", parent=self.stack[-1], text=data)
90
+
91
+ def px(v, d=0):
92
+ m = re.search(r"-?\d+(?:\.\d+)?", str(v or ""))
93
+ return float(m.group()) if m else float(d)
94
+
95
+ def box(v, d=0):
96
+ a = [px(x, d) for x in str(v or "").split()[:4]]
97
+ if not a: return [d]*4
98
+ if len(a)==1: return a*4
99
+ if len(a)==2: return [a[0],a[1],a[0],a[1]]
100
+ if len(a)==3: return [a[0],a[1],a[2],a[1]]
101
+ return a
102
+
103
+ def decl(s):
104
+ out = {}
105
+ for item in (s or "").split(";"):
106
+ if ":" in item:
107
+ k,v = item.split(":",1); out[k.strip().lower()] = v.strip()
108
+ return out
109
+
110
+ def css_rules(css):
111
+ css = re.sub(r"/\*.*?\*/", "", css or "", flags=re.S)
112
+ out=[]
113
+ for sel,body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)[:500]:
114
+ sel=sel.strip()
115
+ if sel.startswith("@"): continue
116
+ declarations=decl(body)
117
+ for item in sel.split(","):
118
+ item=item.strip()
119
+ if item: out.append((item,declarations))
120
+ return out
121
+
122
+ def _simple_selector_matches(n, selector):
123
+ selector=selector.strip()
124
+ if not selector or n.tag in ("#text", "document"): return False
125
+ pseudos=re.findall(r":(first-child|last-child|only-child|nth-child\((\d+)\)|not\(([^()]*)\))", selector)
126
+ clean=re.sub(r":(?:first-child|last-child|only-child|nth-child\(\d+\)|not\([^()]*\))", "", selector)
127
+ attrs=re.findall(r"\[([\w:-]+)\s*(?:(\^=|\$=|\*=|~=|\|=|=)\s*([^\]]*))?\]", clean)
128
+ clean=re.sub(r"\[[^\]]+\]", "", clean).strip()
129
+ tag=re.match(r"^[a-zA-Z][\w-]*|^\*", clean)
130
+ if tag and tag.group() not in ("*",) and n.tag.lower()!=tag.group().lower(): return False
131
+ ident=re.search(r"#([\w-]+)",clean)
132
+ if ident and n.attrs.get("id")!=ident.group(1): return False
133
+ classes=re.findall(r"\.([\w-]+)",clean)
134
+ node_classes=n.attrs.get("class", "").split()
135
+ if any(c not in node_classes for c in classes): return False
136
+ for name,op,val in attrs:
137
+ if name not in n.attrs: return False
138
+ val=(val or "").strip().strip("\"'")
139
+ actual=str(n.attrs.get(name,""))
140
+ if not op: continue
141
+ if op=="=" and actual!=val: return False
142
+ if op=="^=" and not actual.startswith(val): return False
143
+ if op=="$=" and not actual.endswith(val): return False
144
+ if op=="*=" and val not in actual: return False
145
+ if op=="~=" and val not in actual.split(): return False
146
+ if op=="|=" and actual!=val and not actual.startswith(val+"-"): return False
147
+ for pseudo,arg,not_arg in pseudos:
148
+ siblings=[c for c in (n.parent.children if n.parent else []) if c.tag!="#text"]
149
+ if n not in siblings: return False
150
+ idx=siblings.index(n)+1
151
+ if pseudo=="first-child" and idx!=1: return False
152
+ if pseudo=="last-child" and idx!=len(siblings): return False
153
+ if pseudo=="only-child" and len(siblings)!=1: return False
154
+ if pseudo.startswith("nth-child") and idx!=int(arg): return False
155
+ if pseudo.startswith("not") and _simple_selector_matches(n,not_arg): return False
156
+ return bool(tag or ident or classes or attrs or pseudos or clean=="*")
157
+
158
+ def matches(n, selector):
159
+ selector=re.sub(r"\s*([>+~])\s*",r" \1 ",(selector or "").strip())
160
+ tokens=re.findall(r"[^\s>+~]+|[>+~]",selector)
161
+ if not tokens: return False
162
+ compounds=[]; combinators=[]
163
+ for token in tokens:
164
+ if token in ("," , ">","+","~"):
165
+ if token in (">","+","~"):
166
+ if not compounds or len(combinators)!=len(compounds)-1: return False
167
+ combinators.append(token)
168
+ else:
169
+ if compounds and len(combinators)==len(compounds)-1: combinators.append(" ")
170
+ compounds.append(token)
171
+ if len(combinators)!=len(compounds)-1: return False
172
+ cur=n
173
+ if not _simple_selector_matches(cur,compounds[-1]): return False
174
+ for i in range(len(compounds)-2,-1,-1):
175
+ comb=combinators[i]
176
+ if comb==">":
177
+ cur=cur.parent
178
+ if cur is None or not _simple_selector_matches(cur,compounds[i]): return False
179
+ elif comb=="+":
180
+ sibs=[c for c in (cur.parent.children if cur.parent else []) if c.tag!="#text"]
181
+ try: cur=sibs[sibs.index(cur)-1] if sibs.index(cur)>0 else None
182
+ except ValueError: cur=None
183
+ if cur is None or not _simple_selector_matches(cur,compounds[i]): return False
184
+ elif comb=="~":
185
+ sibs=[c for c in (cur.parent.children if cur.parent else []) if c.tag!="#text"]
186
+ try: idx=sibs.index(cur)
187
+ except ValueError: idx=0
188
+ prev=next((x for x in reversed(sibs[:idx]) if _simple_selector_matches(x,compounds[i])),None)
189
+ if prev is None: return False
190
+ cur=prev
191
+ else:
192
+ anc=cur.parent
193
+ while anc is not None and not _simple_selector_matches(anc,compounds[i]): anc=anc.parent
194
+ if anc is None: return False
195
+ cur=anc
196
+ return True
197
+
198
+ def color(v, default="#202124"):
199
+ c=QColor(v or default)
200
+ return c if c.isValid() else QColor(default)
201
+
202
+ class FetchSignals(QObject):
203
+ finished = Signal(int, str, str)
204
+
205
+ class FetchWorker(QRunnable):
206
+ def __init__(self, request_id, url, signals):
207
+ super().__init__(); self.request_id=request_id; self.url=url; self.signals=signals
208
+ def run(self):
209
+ try:
210
+ req=urllib.request.Request(self.url, headers={"User-Agent":"CatEngine/0.5.7"})
211
+ with urllib.request.urlopen(req, timeout=8) as resp:
212
+ data=resp.read().decode("utf-8", errors="replace")
213
+ self.signals.finished.emit(self.request_id, data, "")
214
+ except Exception as exc:
215
+ self.signals.finished.emit(self.request_id, "", str(exc))
216
+
217
+ class JSConsole(QObject):
218
+ @Slot(str)
219
+ def log(self, msg): print("[JS Console]:", msg)
220
+ @Slot(str)
221
+ def error(self, msg): print("[JS Error]:", msg)
222
+
223
+ class DOMBridge(QObject):
224
+ def __init__(self, view):
225
+ super().__init__(); self.view=view
226
+ self._next_request=1; self._pending_fetch={}
227
+ self.fetch_signals=FetchSignals()
228
+ self.fetch_signals.finished.connect(self._fetch_finished)
229
+
230
+ @Slot(str, str, result=str)
231
+ def getStyle(self, handle, prop):
232
+ node=self.view.resolve_node(handle)
233
+ if not node: return ""
234
+ return decl(node.attrs.get("style", "")).get(prop.strip().lower(), "")
235
+
236
+ @Slot(str, result=str)
237
+ def getParentHandle(self, handle):
238
+ node=self.view.resolve_node(handle)
239
+ return self.view.register_node(node.parent) if node and node.parent else ""
240
+
241
+ @Slot(str, result=str)
242
+ def getChildrenHandles(self, handle):
243
+ node=self.view.resolve_node(handle)
244
+ return json.dumps([self.view.register_node(c) for c in node.children if c.tag != "#text"]) if node else "[]"
245
+
246
+ @Slot(str, result=str)
247
+ def getChildNodeHandles(self, handle):
248
+ node=self.view.resolve_node(handle)
249
+ return json.dumps([self.view.register_node(c) for c in node.children]) if node else "[]"
250
+
251
+ @Slot(str, str, str)
252
+ def setStyle(self, elem_id, prop, val):
253
+ target=self.view.resolve_node(elem_id)
254
+ if target:
255
+ target.attrs.setdefault("style", "")
256
+ styles=decl(target.attrs.get("style", "")); styles[prop.lower().strip()]=val.strip()
257
+ target.attrs["style"]="; ".join(f"{k}: {v}" for k,v in styles.items())
258
+ self.view.recalculate_styles(); self.view.update()
259
+
260
+ @Slot(str, str)
261
+ def setInnerHTML(self, elem_id, html_content):
262
+ target=self.view.resolve_node(elem_id)
263
+ if target:
264
+ sub=Parser()
265
+ try:
266
+ sub.feed(html_content); sub.close()
267
+ target.children=[]
268
+ for child in sub.root.children:
269
+ child.parent=target; target.children.append(child)
270
+ self.view.recalculate_styles(); self.view.update()
271
+ except Exception as exc: print("innerHTML error:", exc)
272
+
273
+ @Slot(str, result=str)
274
+ def findHandle(self, elem_id):
275
+ node = self.view.find_by_id(self.view.root, elem_id)
276
+ return self.view.register_node(node) if node else ""
277
+
278
+ @Slot(str, result=str)
279
+ def getTagName(self, handle):
280
+ node=self.view.resolve_node(handle)
281
+ return node.tag.upper() if node and node.tag != "#text" else "#text"
282
+
283
+ @Slot(str, str, result=str)
284
+ def getAttribute(self, handle, name):
285
+ node=self.view.resolve_node(handle)
286
+ if not node: return ""
287
+ name=name.lower().strip()
288
+ if name == "value" and node.tag == "input":
289
+ return node.attrs.get("_input_val", node.attrs.get("value", ""))
290
+ return str(node.attrs.get(name, ""))
291
+
292
+ @Slot(str, str)
293
+ def removeAttribute(self, handle, name):
294
+ node=self.view.resolve_node(handle)
295
+ name=name.lower().strip()
296
+ if node and name in node.attrs and not name.startswith("on") and not name.startswith("_"):
297
+ del node.attrs[name]
298
+ self.view.recalculate_styles(); self.view.update()
299
+
300
+ @Slot(str, result=str)
301
+ def querySelector(self, selector):
302
+ node=self.view.query_selector(selector)
303
+ return self.view.register_node(node) if node else ""
304
+
305
+ @Slot(str, result=str)
306
+ def querySelectorAll(self, selector):
307
+ nodes=self.view.query_selector_all(selector)
308
+ return json.dumps([self.view.register_node(node) for node in nodes])
309
+
310
+ @Slot(str, str)
311
+ def removeChild(self, parent_handle, child_handle):
312
+ parent=self.view.resolve_node(parent_handle); child=self.view.resolve_node(child_handle)
313
+ if parent and child and child.parent is parent and child in parent.children:
314
+ parent.children.remove(child); child.parent=None
315
+ self.view.recalculate_styles(); self.view.update()
316
+
317
+ @Slot(str, result=str)
318
+ def getTextContent(self, handle):
319
+ node = self.view.resolve_node(handle)
320
+ return node.text_content() if node else ""
321
+
322
+ @Slot(str, result=str)
323
+ def getInnerHTML(self, handle):
324
+ node = self.view.resolve_node(handle)
325
+ if not node: return ""
326
+ def serialize(n):
327
+ if n.tag == "#text":
328
+ return (n.text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
329
+ attrs = "".join(" " + k + '=\"' + str(v).replace("&", "&amp;").replace('\"', "&quot;") + '\"' for k, v in n.attrs.items() if not k.startswith("_"))
330
+ if n.tag in VOID: return "<" + n.tag + attrs + ">"
331
+ return "<" + n.tag + attrs + ">" + "".join(serialize(c) for c in n.children) + "</" + n.tag + ">"
332
+ return "".join(serialize(c) for c in node.children)
333
+
334
+ @Slot(str, result=str)
335
+ def createTextNode(self, text):
336
+ parent = self.view.find(self.view.root, "body") or self.view.root
337
+ node = Node("#text", parent=parent, text=text)
338
+ return self.view.register_node(node)
339
+
340
+ @Slot(str, str, result=str)
341
+ def createElement(self, tag_name, parent_id):
342
+ tag=tag_name.strip().lower()
343
+ if not re.fullmatch(r"[a-z][a-z0-9-]*", tag) or tag in ("script", "iframe", "object", "embed"):
344
+ return ""
345
+ parent=self.view.find_by_id(self.view.root, parent_id) if parent_id else (self.view.find(self.view.root,"body") or self.view.root)
346
+ if not parent: return ""
347
+ node=Node(tag=tag, parent=parent)
348
+ handle=self.view.register_node(node)
349
+ self.view.recalculate_styles(); self.view.update()
350
+ return handle
351
+
352
+ @Slot(str, str, str)
353
+ def insertBefore(self, parent_handle, child_handle, ref_handle):
354
+ parent=self.view.resolve_node(parent_handle); child=self.view.resolve_node(child_handle)
355
+ ref=self.view.resolve_node(ref_handle) if ref_handle else None
356
+ if not parent or not child or parent is child or (ref is not None and ref.parent is not parent): return
357
+ cur=parent
358
+ while cur:
359
+ if cur is child: return
360
+ cur=cur.parent
361
+ if child.parent and child in child.parent.children: child.parent.children.remove(child)
362
+ child.parent=parent
363
+ if ref is not None and ref in parent.children: parent.children.insert(parent.children.index(ref),child)
364
+ else: parent.children.append(child)
365
+ self.view.recalculate_styles(); self.view.update()
366
+
367
+ @Slot(str, str, result=bool)
368
+ def appendChild(self, parent_id, child_handle):
369
+ parent=self.view.resolve_node(parent_id)
370
+ child=self.view.resolve_node(child_handle)
371
+ if not parent or not child or parent is child: return False
372
+ cur=parent
373
+ while cur:
374
+ if cur is child: return False
375
+ cur=cur.parent
376
+ if child.parent and child in child.parent.children: child.parent.children.remove(child)
377
+ child.parent=parent; parent.children.append(child)
378
+ self.view.recalculate_styles(); self.view.update(); return True
379
+
380
+ @Slot(str, str)
381
+ def setTextContent(self, elem_id, text):
382
+ node=self.view.resolve_node(elem_id)
383
+ if node:
384
+ node.children=[]
385
+ Node("#text", parent=node, text=text)
386
+ self.view.recalculate_styles(); self.view.update()
387
+
388
+ @Slot(str, result=str)
389
+ def getInputValue(self, elem_id):
390
+ node = self.view.resolve_node(elem_id)
391
+ if not node or node.tag != "input": return ""
392
+ return node.attrs.get("_input_val", node.attrs.get("value", ""))
393
+
394
+ @Slot(str, str)
395
+ def setInputValue(self, elem_id, value):
396
+ node = self.view.resolve_node(elem_id)
397
+ if node and node.tag == "input":
398
+ node.attrs["_input_val"] = value
399
+ self.view.update()
400
+
401
+ @Slot(str, str)
402
+ def setAttribute(self, elem_id, name, value):
403
+ node=self.view.resolve_node(elem_id)
404
+ name=name.lower().strip()
405
+ if node and re.fullmatch(r"[a-z_:][a-z0-9_.:-]*", name) and not name.startswith("on"):
406
+ node.attrs[name]=value
407
+ self.view.recalculate_styles(); self.view.update()
408
+
409
+ @Slot(str, str, str)
410
+ def addEventListener(self, elem_id, event_type, callback_id):
411
+ node=self.view.resolve_node(elem_id)
412
+ event_type=event_type.lower().strip()
413
+ if node and event_type in ("click", "input", "change") and callback_id:
414
+ listeners = node.attrs.setdefault("_listeners", {})
415
+ existing = listeners.get(event_type, [])
416
+ if isinstance(existing, str): existing = [existing]
417
+ else: existing = list(existing)
418
+ existing.append(callback_id)
419
+ listeners[event_type] = existing
420
+
421
+ @Slot(str, str, int)
422
+ def setTimeout(self, callback_id, code, delay_ms):
423
+ delay=max(0, min(60000, int(delay_ms)))
424
+ QTimer.singleShot(delay, lambda: self.view.run_timeout(callback_id, code))
425
+
426
+ @Slot(str, result=int)
427
+ def startFetch(self, url):
428
+ full=urljoin(self.view.current_url or str(self.view.base), url)
429
+ parsed=urlparse(full)
430
+ if parsed.scheme not in ("http", "https"): return -1
431
+ request_id=self._next_request; self._next_request+=1
432
+ self._pending_fetch[request_id]=True
433
+ QThreadPool.globalInstance().start(FetchWorker(request_id, full, self.fetch_signals))
434
+ return request_id
435
+
436
+ @Slot(int, result=bool)
437
+ def hasFetchResult(self, request_id):
438
+ return request_id in self._pending_fetch and isinstance(self._pending_fetch[request_id], tuple)
439
+
440
+ @Slot(int, result=str)
441
+ def takeFetchResult(self, request_id):
442
+ item=self._pending_fetch.pop(request_id, None)
443
+ if not isinstance(item, tuple): return json.dumps({"error":"Request is not ready"})
444
+ body, error=item
445
+ return json.dumps({"ok": not bool(error), "text": body, "error": error})
446
+
447
+ @Slot(int, str, str)
448
+ def _fetch_finished(self, request_id, body, error):
449
+ if request_id in self._pending_fetch:
450
+ self._pending_fetch[request_id]=(body,error)
451
+
452
+ class CatEngine(QWidget):
453
+ """Embeddable HTML rendering engine widget."""
454
+ linkClicked = Signal(str)
455
+
456
+ def __init__(self, parent=None):
457
+ super().__init__(parent)
458
+ self.root = Node()
459
+ self.styles = {}
460
+ self.hit = []
461
+ self.floats = []
462
+ self.base = Path.cwd()
463
+ self.current_url = ""
464
+ self.title = "CatEngine"
465
+ self.setMinimumSize(600, 450)
466
+ self.setMouseTracking(True)
467
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
468
+ self.focused_input = None
469
+ self.js_engine = None
470
+ self._node_handles = {}
471
+ self._handle_nodes = {}
472
+ self._next_handle = 1
473
+ self.js_console = None
474
+ self.dom_bridge = None
475
+
476
+ if HAS_JS:
477
+ try:
478
+ self.js_engine = QJSEngine()
479
+ self.js_console = JSConsole()
480
+ self.dom_bridge = DOMBridge(self)
481
+ glob = self.js_engine.globalObject()
482
+ glob.setProperty("console", self.js_engine.newQObject(self.js_console))
483
+ glob.setProperty("__dom", self.js_engine.newQObject(self.dom_bridge))
484
+ bootstrap = r'''
485
+ var __purremiumCallbackCounter = 1;
486
+ var __purremiumCallbacks = {};
487
+ function makeElement(handle, tag){
488
+ var styleObject={
489
+ setProperty:function(p,v){__dom.setStyle(handle,String(p),String(v));},
490
+ getPropertyValue:function(p){return __dom.getStyle(handle,String(p));},
491
+ removeProperty:function(p){var old=__dom.getStyle(handle,String(p));__dom.setStyle(handle,String(p),'');return old;},
492
+ get cssText(){return __dom.getAttribute(handle,'style');},
493
+ set cssText(v){__dom.setAttribute(handle,'style',String(v));}
494
+ };
495
+ ['color','background','backgroundColor','backgroundImage','fontSize','fontFamily','fontWeight','fontStyle','textDecoration','display','width','height','margin','padding','border','borderRadius','textAlign','lineHeight','float','gap','flexDirection','alignItems','justifyContent','overflow'].forEach(function(k){
496
+ var cssName=k.replace(/[A-Z]/g,function(c){return '-'+c.toLowerCase();});
497
+ Object.defineProperty(styleObject,k,{get:function(){return __dom.getStyle(handle,cssName);},set:function(v){__dom.setStyle(handle,cssName,String(v));},enumerable:true});
498
+ });
499
+ var wrapper = {__handle:handle, style:styleObject,
500
+ setAttribute:function(n,v){__dom.setAttribute(handle,String(n),String(v));},
501
+ getAttribute:function(n){return __dom.getAttribute(handle,String(n));},
502
+ hasAttribute:function(n){return __dom.getAttribute(handle,String(n))!=='';},
503
+ toggleAttribute:function(n,force){n=String(n);var has=this.hasAttribute(n);if(force===true||(!has&&force!==false)){__dom.setAttribute(handle,n,'');return true;}if(has)__dom.removeAttribute(handle,n);return false;},
504
+ removeAttribute:function(n){__dom.removeAttribute(handle,String(n));},
505
+ get tagName(){return __dom.getTagName(handle);},
506
+ get id(){return __dom.getAttribute(handle,'id');},
507
+ set id(v){__dom.setAttribute(handle,'id',String(v));},
508
+ get className(){return __dom.getAttribute(handle,'class');},
509
+ set className(v){__dom.setAttribute(handle,'class',String(v));},
510
+ get classList(){
511
+ var el=this;
512
+ return {contains:function(c){return el.className.split(/\s+/).indexOf(String(c))>=0;},
513
+ add:function(){var a=el.className.split(/\s+/).filter(Boolean);for(var i=0;i<arguments.length;i++)if(a.indexOf(String(arguments[i]))<0)a.push(String(arguments[i]));el.className=a.join(' ');},
514
+ remove:function(){var a=el.className.split(/\s+/).filter(Boolean);for(var i=0;i<arguments.length;i++)a=a.filter(function(x){return x!==String(arguments[i]);});el.className=a.join(' ');},
515
+ toggle:function(c,force){c=String(c);var has=this.contains(c);if(force===true||(!has&&force!==false)){this.add(c);return true;}if(has)this.remove(c);return false;},
516
+ toString:function(){return el.className;}};
517
+ },
518
+ get value(){return __dom.getInputValue(handle);},
519
+ set value(v){__dom.setInputValue(handle,String(v));},
520
+ addEventListener:function(type,fn){
521
+ if(typeof fn!=='function') return;
522
+ var id='cb'+(__purremiumCallbackCounter++);
523
+ __purremiumCallbacks[id]=fn;
524
+ __dom.addEventListener(handle,String(type),id);
525
+ },
526
+ appendChild:function(child){if(child&&child.__handle)__dom.appendChild(handle,child.__handle);return child;},
527
+ removeChild:function(child){if(child&&child.__handle)__dom.removeChild(handle,child.__handle);return child;},
528
+ insertBefore:function(child,ref){if(child&&child.__handle)__dom.insertBefore(handle,child.__handle,ref&&ref.__handle?ref.__handle:'');return child;},
529
+ remove:function(){var ph=__dom.getParentHandle(handle);if(ph)__dom.removeChild(ph,handle);},
530
+ contains:function(other){var cur=other;while(cur){if(cur.__handle===handle)return true;var ph=__dom.getParentHandle(cur.__handle);cur=ph?makeElement(ph,__dom.getTagName(ph)):null;}return false;},
531
+ get parentElement(){var h=__dom.getParentHandle(handle);return h?makeElement(h,__dom.getTagName(h)):null;},
532
+ get children(){return simpleNodeList(JSON.parse(__dom.getChildrenHandles(handle)));},
533
+ get childNodes(){return simpleNodeList(JSON.parse(__dom.getChildNodeHandles(handle)));},
534
+ get firstElementChild(){var a=this.children;return a.length?a[0]:null;},
535
+ get lastElementChild(){var a=this.children;return a.length?a[a.length-1]:null;},
536
+ get childElementCount(){return this.children.length;},
537
+ get innerText(){return __dom.getTextContent(handle);},
538
+ set innerText(v){__dom.setTextContent(handle,String(v));},
539
+ set textContent(v){__dom.setTextContent(handle,String(v));},
540
+ get textContent(){return __dom.getTextContent(handle);},
541
+ set innerHTML(v){__dom.setInnerHTML(handle,String(v));},
542
+ get innerHTML(){return __dom.getInnerHTML(handle);}
543
+ };
544
+ return wrapper;
545
+ }
546
+ function wrapHandle(h){return h?makeElement(h,__dom.getTagName(h)):null;}
547
+ function simpleNodeList(handles){
548
+ var list=handles.map(wrapHandle);
549
+ list.item=function(i){return list[i]||null;};
550
+ return list;
551
+ }
552
+ var document={
553
+ getElementById:function(id){return wrapHandle(__dom.findHandle(String(id)));},
554
+ querySelector:function(selector){return wrapHandle(__dom.querySelector(String(selector)));},
555
+ querySelectorAll:function(selector){return simpleNodeList(JSON.parse(__dom.querySelectorAll(String(selector))));},
556
+ getElementsByTagName:function(tag){return document.querySelectorAll(String(tag));},
557
+ getElementsByClassName:function(cls){return document.querySelectorAll('.'+String(cls).trim().split(/\s+/).join('.'));},
558
+ getElementsByName:function(name){return document.querySelectorAll('[name="'+String(name).replace(/"/g,'\\"')+'"]');},
559
+ createElement:function(tag){var h=__dom.createElement(String(tag),'');return h?makeElement(h,tag):null;},
560
+ createTextNode:function(text){var h=__dom.createTextNode(String(text));return h?makeElement(h,'#text'):null;},
561
+ body:null,
562
+ write:function(text){console.log('document.write is not implemented.');}
563
+ };
564
+ function setTimeout(fn,delay){
565
+ var id='cb'+(__purremiumCallbackCounter++);
566
+ if(typeof fn==='function')__purremiumCallbacks[id]=fn;
567
+ __dom.setTimeout(id,typeof fn==='string'?fn:'',Number(delay)||0);return id;
568
+ }
569
+ function __runCallback(id){
570
+ var fn=__purremiumCallbacks[id];
571
+ if(typeof fn!=='function'){console.error('Callback not found: '+id);return false;}
572
+ try{fn();return true;}catch(e){console.error('Callback error: '+String(e));return false;}
573
+ }
574
+ function fetch(url){
575
+ return new Promise(function(resolve,reject){
576
+ var id=__dom.startFetch(String(url));
577
+ if(id<0){reject(new Error('Only http and https supported'));return;}
578
+ var poll=function(){
579
+ if(__dom.hasFetchResult(id)){
580
+ var result=JSON.parse(__dom.takeFetchResult(id));
581
+ if(!result.ok){reject(new Error(result.error||'Fetch failed'));return;}
582
+ resolve({ok:true,status:200,text:function(){return Promise.resolve(result.text);},json:function(){try{return Promise.resolve(JSON.parse(result.text));}catch(e){return Promise.reject(e);}}});
583
+ }else{setTimeout(poll,25);}
584
+ };poll();
585
+ });
586
+ }
587
+ function alert(msg){console.log(String(msg));}
588
+ this.document=document;this.setTimeout=setTimeout;this.fetch=fetch;this.alert=alert;
589
+ this.__runCallback=__runCallback;
590
+ '''
591
+ self.js_engine.evaluate(bootstrap)
592
+ except Exception as exc:
593
+ print("JS initialization error:", exc)
594
+ self.js_engine = None
595
+
596
+ def register_node(self, node):
597
+ key = id(node)
598
+ if key not in self._node_handles:
599
+ handle = "node" + str(self._next_handle)
600
+ self._next_handle += 1
601
+ self._node_handles[key] = handle
602
+ self._handle_nodes[handle] = node
603
+ return self._node_handles[key]
604
+
605
+ def resolve_node(self, handle):
606
+ if not handle: return None
607
+ if handle in self._handle_nodes: return self._handle_nodes[handle]
608
+ return self.find_by_id(self.root, handle)
609
+
610
+ def find(self, n, tag):
611
+ if n.tag == tag: return n
612
+ for c in n.children:
613
+ found = self.find(c, tag)
614
+ if found: return found
615
+ return None
616
+
617
+ def find_by_id(self, n, elem_id):
618
+ if n.attrs.get("id") == elem_id: return n
619
+ for c in n.children:
620
+ found = self.find_by_id(c, elem_id)
621
+ if found: return found
622
+ return None
623
+
624
+ def query_selector_all(self, selector):
625
+ selector = (selector or "").strip()
626
+ if not selector or len(selector) > 300: return []
627
+ groups = [p.strip() for p in selector.split(",") if p.strip()]
628
+ if not groups: return []
629
+ found = []
630
+ def walk(node):
631
+ for child in node.children:
632
+ if child.tag != "#text" and any(matches(child, g) for g in groups):
633
+ found.append(child)
634
+ walk(child)
635
+ walk(self.root)
636
+ return found
637
+
638
+ def query_selector(self, selector):
639
+ found = self.query_selector_all(selector)
640
+ return found[0] if found else None
641
+
642
+ def recalculate_styles(self):
643
+ css = []
644
+ def gather(n):
645
+ if n.tag == "style": css.append(n.text_content())
646
+ for ch in n.children: gather(ch)
647
+ gather(self.root)
648
+ rules = css_rules("\n".join(getattr(self, "external_css", []) + getattr(self, "inline_css", []) + css))
649
+ new_styles = {}
650
+ inherited = {"color", "font-family", "font-size", "font-weight", "font-style", "line-height", "text-align", "visibility", "white-space"}
651
+ def specificity(selector):
652
+ ids = len(re.findall(r"#[\w-]+", selector))
653
+ classes = len(re.findall(r"\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+", selector))
654
+ tags = len(re.findall(r"(?:^|[ >+~])(?:[a-zA-Z][\w-]*)", selector))
655
+ return (ids, classes, tags)
656
+ ordered = sorted(enumerate(rules), key=lambda pair: (specificity(pair[1][0]), pair[0]))
657
+ def walk(n, parent_style=None):
658
+ if n.tag == "#text": return
659
+ st = {}
660
+ if parent_style:
661
+ st.update({k: v for k, v in parent_style.items() if k in inherited})
662
+ st.update(DEFAULT.get(n.tag, {}))
663
+ for _, (selector, declarations) in ordered:
664
+ if matches(n, selector): st.update(declarations)
665
+ st.update(decl(n.attrs.get("style", "")))
666
+ if n.attrs.get("hidden") is not None: st["display"] = "none"
667
+ new_styles[id(n)] = st
668
+ for ch in n.children: walk(ch, st)
669
+ walk(self.root)
670
+ self.styles = new_styles
671
+
672
+ def load_html(self, html, base=None, current_url=""):
673
+ self.current_url = current_url
674
+ self.setMinimumHeight(450)
675
+ parser = Parser()
676
+ try:
677
+ parser.feed(html)
678
+ parser.close()
679
+ except Exception as exc:
680
+ print("HTML parse warning:", exc)
681
+ self.root = parser.root
682
+ self.external_css = []
683
+ self.inline_css = list(parser.css[:20])
684
+ self.base = Path(base) if base else Path.cwd()
685
+ self._node_handles = {}
686
+ self._handle_nodes = {}
687
+ self._next_handle = 1
688
+ self.title = "CatEngine"
689
+ title_node = self.find(self.root, "title")
690
+ if title_node and title_node.text_content().strip():
691
+ self.title = title_node.text_content().strip()
692
+ self.recalculate_styles()
693
+ if self.js_engine:
694
+ self.js_engine.evaluate("__purremiumCallbacks = {}; __purremiumCallbackCounter = 1;")
695
+ if self.js_engine and parser.scripts:
696
+ body_handle = self.register_node(self.find(self.root, 'body') or self.root)
697
+ self.js_engine.evaluate("document.body = (function(){var h=" + json.dumps(body_handle) + ";return makeElement(h,__dom.getTagName(h));})();")
698
+ for script in parser.scripts:
699
+ if script.strip():
700
+ try: self.js_engine.evaluate(script)
701
+ except Exception as exc: print("JS error:", exc)
702
+ self.update()
703
+
704
+ def load_file(self, filename):
705
+ try:
706
+ path = Path(filename)
707
+ self.load_html(path.read_text(encoding="utf-8", errors="replace"), path.resolve().parent, str(path.resolve()))
708
+ except OSError as exc:
709
+ self.load_html(f"<h1>Could not open file</h1><p>{exc}</p>")
710
+
711
+ def st(self, n):
712
+ d = dict(DEFAULT.get(n.tag, {}))
713
+ d.update(self.styles.get(id(n), {}))
714
+ return d
715
+
716
+ def font(self, n, inh):
717
+ s = self.st(n)
718
+ old = inh.get("font", QFont("Segoe UI", 16))
719
+ f = QFont(s.get("font-family", inh.get("family", "Segoe UI")), int(max(8, min(72, px(s.get("font-size"), old.pointSize())))))
720
+ if s.get("font-weight", "").lower() in ("bold", "700", "800", "900") or n.tag in ("h1", "h2", "h3", "h4", "h5", "h6", "b", "strong", "th"): f.setBold(True)
721
+ if n.tag in ("i", "em"): f.setItalic(True)
722
+ if n.tag in ("u", "a") or "underline" in s.get("text-decoration", ""): f.setUnderline(True)
723
+ return f, color(s.get("color", inh.get("color", "#202124"))), s.get("font-family", inh.get("family", "Segoe UI")), s
724
+
725
+ def paintEvent(self, event):
726
+ p = QPainter(self)
727
+ p.setRenderHint(QPainter.RenderHint.TextAntialiasing)
728
+ p.fillRect(self.rect(), QColor("white"))
729
+ self.hit = []
730
+ self.floats = []
731
+ body = self.find(self.root, "body") or self.root
732
+ st = self.st(body)
733
+ p.fillRect(self.rect(), color(st.get("background", st.get("background-color", "white")), "white"))
734
+ margins = box(st.get("margin", "16px"), 16)
735
+ content_bottom = self.blocks(
736
+ p, body, margins[3], margins[0],
737
+ max(100, self.width() - margins[1] - margins[3]),
738
+ {"font": QFont("Segoe UI", 16), "color": QColor("#202124"), "family": "Segoe UI"}
739
+ )
740
+ p.end()
741
+ required_height = max(450, int(content_bottom + margins[2] + 24))
742
+ if self.minimumHeight() != required_height:
743
+ self.setMinimumHeight(required_height)
744
+
745
+ def edges(self, y, x, w):
746
+ left, right = x, x + w
747
+ for fx, fy, fw, fh, side in self.floats:
748
+ if fy <= y < fy + fh:
749
+ if side == "left": left = max(left, fx + fw + 8)
750
+ else: right = min(right, fx - 8)
751
+ return (left, right) if right - left > 35 else (x, x + w)
752
+
753
+ def blocks(self, p, parent, x, y, w, inh):
754
+ runs = []
755
+ def flush():
756
+ nonlocal y
757
+ if runs: y = self.inline(p, runs, x, y, w, inh); runs.clear()
758
+ for n in parent.children:
759
+ if n.tag == "#text": runs.append((n.text, parent, inh)); continue
760
+ if n.tag in SKIP: continue
761
+ if n.tag == "br": runs.append(("\n", n, inh)); continue
762
+ s = self.st(n)
763
+ display = s.get("display", "").lower()
764
+ block_display = display in ("block", "flex", "grid", "flow-root", "list-item", "table", "table-row", "table-cell")
765
+ inline_display = display in ("inline", "inline-block", "inline-flex", "inline-grid")
766
+ is_block =
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: catengine
3
+ Version: 0.5.7
4
+ Summary: A lightweight PySide6 HTML/CSS browser rendering engine widget.
5
+ Author-email: PurrEmium Team <your-email@example.com>
6
+ Project-URL: Homepage, https://github.com/yourusername/catengine
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: User Interfaces
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.txt
16
+ Requires-Dist: PySide6>=6.0.0
17
+ Dynamic: license-file
18
+
19
+ # CatEngine 🐱
20
+
21
+ A lightweight, custom PySide6 HTML/CSS rendering engine widget built on `QPainter` and `QJSEngine`.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install catengine
@@ -0,0 +1,10 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/catengine/__init__.py
5
+ src/catengine/catengine.py
6
+ src/catengine.egg-info/PKG-INFO
7
+ src/catengine.egg-info/SOURCES.txt
8
+ src/catengine.egg-info/dependency_links.txt
9
+ src/catengine.egg-info/requires.txt
10
+ src/catengine.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ PySide6>=6.0.0
@@ -0,0 +1 @@
1
+ catengine