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.
- catengine-0.5.7/LICENSE.txt +25 -0
- catengine-0.5.7/PKG-INFO +26 -0
- catengine-0.5.7/README.md +8 -0
- catengine-0.5.7/pyproject.toml +27 -0
- catengine-0.5.7/setup.cfg +4 -0
- catengine-0.5.7/src/catengine/__init__.py +4 -0
- catengine-0.5.7/src/catengine/catengine.py +766 -0
- catengine-0.5.7/src/catengine.egg-info/PKG-INFO +26 -0
- catengine-0.5.7/src/catengine.egg-info/SOURCES.txt +10 -0
- catengine-0.5.7/src/catengine.egg-info/dependency_links.txt +1 -0
- catengine-0.5.7/src/catengine.egg-info/requires.txt +1 -0
- catengine-0.5.7/src/catengine.egg-info/top_level.txt +1 -0
|
@@ -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.
|
catengine-0.5.7/PKG-INFO
ADDED
|
@@ -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,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,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("&", "&").replace("<", "<").replace(">", ">"))
|
|
329
|
+
attrs = "".join(" " + k + '=\"' + str(v).replace("&", "&").replace('\"', """) + '\"' 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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PySide6>=6.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
catengine
|