markserv 1.0.0__py3-none-any.whl
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.
- markserv/__init__.py +3 -0
- markserv/__main__.py +3 -0
- markserv/app.py +4 -0
- markserv/cli.py +279 -0
- markserv/demo.py +178 -0
- markserv/icons.py +182 -0
- markserv/public/css/app.css +675 -0
- markserv/public/css/github-markdown-dark.css +1124 -0
- markserv/public/css/github-markdown-light.css +1124 -0
- markserv/public/js/clipboard.js +31 -0
- markserv/public/js/dev-reload.js +30 -0
- markserv/public/js/favicon.js +29 -0
- markserv/public/js/sidebar.js +100 -0
- markserv/public/js/theme.js +130 -0
- markserv/public/licenses/github-markdown-css.LICENSE +9 -0
- markserv/public/licenses/htmx.LICENSE +13 -0
- markserv/public/vendor/htmx.min.js +1 -0
- markserv/public/vendor/sse.js +290 -0
- markserv/rendering.py +458 -0
- markserv/site.py +401 -0
- markserv/web.py +313 -0
- markserv-1.0.0.dist-info/METADATA +111 -0
- markserv-1.0.0.dist-info/RECORD +25 -0
- markserv-1.0.0.dist-info/WHEEL +4 -0
- markserv-1.0.0.dist-info/entry_points.txt +4 -0
markserv/__init__.py
ADDED
markserv/__main__.py
ADDED
markserv/app.py
ADDED
markserv/cli.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import select
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import webbrowser
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any, Protocol
|
|
13
|
+
|
|
14
|
+
import uvicorn
|
|
15
|
+
from cyclopts import App, Parameter
|
|
16
|
+
from cyclopts.help import PlainFormatter
|
|
17
|
+
from cyclopts.token import Token
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.logging import RichHandler
|
|
20
|
+
from uvicorn import Config, Server
|
|
21
|
+
|
|
22
|
+
from .app import create_app
|
|
23
|
+
from .site import build_config, build_file_site
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StoppableServer(Protocol):
|
|
27
|
+
should_exit: bool
|
|
28
|
+
|
|
29
|
+
def run(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
console = Console(stderr=True)
|
|
33
|
+
QUIT_KEYS = {"q", "Q", "\x1b"}
|
|
34
|
+
DEFAULT_HOST = "localhost"
|
|
35
|
+
DEFAULT_PORT = 4422
|
|
36
|
+
PYTHON_RELOAD_ENV_VAR = "MARKSERV_PYTHON_RELOAD"
|
|
37
|
+
TARGET_ENV_VAR = "MARKSERV_TARGET"
|
|
38
|
+
PYTHON_RELOAD_DIR = Path(__file__).resolve().parent
|
|
39
|
+
|
|
40
|
+
app = App(
|
|
41
|
+
name="markserv",
|
|
42
|
+
help="Render a folder of GitHub-flavored markdown with live reload.",
|
|
43
|
+
help_formatter=PlainFormatter(),
|
|
44
|
+
result_action="return_value",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_target(_type_: Any, tokens: tuple[Token, ...]) -> Path:
|
|
49
|
+
raw_path = Path(tokens[0].value)
|
|
50
|
+
build_config(raw_path)
|
|
51
|
+
return raw_path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def configure_logging() -> None:
|
|
55
|
+
logging.basicConfig(
|
|
56
|
+
level=logging.INFO,
|
|
57
|
+
format="%(message)s",
|
|
58
|
+
handlers=[
|
|
59
|
+
RichHandler(
|
|
60
|
+
show_time=False,
|
|
61
|
+
show_level=False,
|
|
62
|
+
show_path=False,
|
|
63
|
+
markup=False,
|
|
64
|
+
rich_tracebacks=True,
|
|
65
|
+
console=console,
|
|
66
|
+
)
|
|
67
|
+
],
|
|
68
|
+
force=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def browser_url(host: str, port: int) -> str:
|
|
73
|
+
public_host = "localhost" if host in {"127.0.0.1", "0.0.0.0", "localhost"} else host
|
|
74
|
+
return f"http://{public_host}:{port}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def python_reload_enabled() -> bool:
|
|
78
|
+
value = os.environ.get(PYTHON_RELOAD_ENV_VAR, "")
|
|
79
|
+
return value.lower() in {"1", "true", "yes", "on"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextlib.contextmanager
|
|
83
|
+
def temporary_env(updates: Mapping[str, str]) -> Any:
|
|
84
|
+
previous = {key: os.environ.get(key) for key in updates}
|
|
85
|
+
os.environ.update(updates)
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
finally:
|
|
89
|
+
for key, value in previous.items():
|
|
90
|
+
if value is None:
|
|
91
|
+
os.environ.pop(key, None)
|
|
92
|
+
else:
|
|
93
|
+
os.environ[key] = value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def print_startup_banner(*, source: str, root_dir: str, url: str, open_browser: bool, python_reload: bool) -> None:
|
|
97
|
+
quit_hint = (
|
|
98
|
+
"Press Ctrl+C to quit."
|
|
99
|
+
if python_reload
|
|
100
|
+
else "Press Q or Esc to quit."
|
|
101
|
+
if _supports_quit_prompt()
|
|
102
|
+
else "Press Ctrl+C to quit."
|
|
103
|
+
)
|
|
104
|
+
browser_hint = "Browser opens automatically." if open_browser else "Browser auto-open disabled."
|
|
105
|
+
reload_hint = "Python reload enabled via MARKSERV_PYTHON_RELOAD." if python_reload else None
|
|
106
|
+
display_url = url.removeprefix("http://")
|
|
107
|
+
|
|
108
|
+
console.print(f"[bold cyan]markserv[/] serving {source}")
|
|
109
|
+
console.print(f"[cyan]root[/] {root_dir}")
|
|
110
|
+
console.print(f"[cyan]url[/] [link={url}][underline]{display_url}[/underline][/link]")
|
|
111
|
+
console.print(f"[dim]{browser_hint}[/]")
|
|
112
|
+
if reload_hint is not None:
|
|
113
|
+
console.print(f"[dim]{reload_hint}[/]")
|
|
114
|
+
console.print(f"[dim]{quit_hint}[/]")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_server(app: Any, *, host: str, port: int) -> Server:
|
|
118
|
+
return Server(
|
|
119
|
+
Config(
|
|
120
|
+
app,
|
|
121
|
+
host=host,
|
|
122
|
+
port=port,
|
|
123
|
+
log_level="warning",
|
|
124
|
+
access_log=False,
|
|
125
|
+
log_config=None,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_python_reloading_server(app_factory_import: str, *, host: str, port: int) -> None:
|
|
131
|
+
uvicorn.run(
|
|
132
|
+
app_factory_import,
|
|
133
|
+
factory=True,
|
|
134
|
+
host=host,
|
|
135
|
+
port=port,
|
|
136
|
+
reload=True,
|
|
137
|
+
reload_dirs=[str(PYTHON_RELOAD_DIR)],
|
|
138
|
+
log_level="warning",
|
|
139
|
+
access_log=False,
|
|
140
|
+
log_config=None,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _supports_quit_prompt() -> bool:
|
|
145
|
+
return sys.stdin.isatty() and os.name != "nt"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _request_server_shutdown(server: StoppableServer, stop_event: threading.Event) -> None:
|
|
149
|
+
if stop_event.is_set():
|
|
150
|
+
return
|
|
151
|
+
console.print("[dim]Stopping server...[/dim]")
|
|
152
|
+
server.should_exit = True
|
|
153
|
+
stop_event.set()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _listen_for_quit_keys(server: StoppableServer, stop_event: threading.Event) -> None:
|
|
157
|
+
import termios
|
|
158
|
+
import tty
|
|
159
|
+
|
|
160
|
+
with contextlib.suppress(termios.error, ValueError, OSError):
|
|
161
|
+
fd = sys.stdin.fileno()
|
|
162
|
+
original_attrs = termios.tcgetattr(fd)
|
|
163
|
+
try:
|
|
164
|
+
tty.setcbreak(fd)
|
|
165
|
+
while not stop_event.is_set():
|
|
166
|
+
readable, _writable, _errors = select.select([sys.stdin], [], [], 0.1)
|
|
167
|
+
if not readable:
|
|
168
|
+
continue
|
|
169
|
+
key = sys.stdin.read(1)
|
|
170
|
+
if key in QUIT_KEYS:
|
|
171
|
+
_request_server_shutdown(server, stop_event)
|
|
172
|
+
return
|
|
173
|
+
finally:
|
|
174
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def run_server(server: StoppableServer) -> None:
|
|
178
|
+
stop_event = threading.Event()
|
|
179
|
+
listener: threading.Thread | None = None
|
|
180
|
+
|
|
181
|
+
if _supports_quit_prompt():
|
|
182
|
+
listener = threading.Thread(
|
|
183
|
+
target=_listen_for_quit_keys,
|
|
184
|
+
args=(server, stop_event),
|
|
185
|
+
daemon=True,
|
|
186
|
+
name="markserv-quit-listener",
|
|
187
|
+
)
|
|
188
|
+
listener.start()
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
server.run()
|
|
192
|
+
finally:
|
|
193
|
+
stop_event.set()
|
|
194
|
+
if listener is not None:
|
|
195
|
+
listener.join(timeout=0.2)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def serve_application(
|
|
199
|
+
application: Any | None,
|
|
200
|
+
*,
|
|
201
|
+
source: str,
|
|
202
|
+
root_dir: str,
|
|
203
|
+
host: str,
|
|
204
|
+
port: int,
|
|
205
|
+
open_browser: bool,
|
|
206
|
+
app_factory_import: str | None = None,
|
|
207
|
+
env_updates: Mapping[str, str] | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
configure_logging()
|
|
210
|
+
url = browser_url(host, port)
|
|
211
|
+
python_reload = python_reload_enabled()
|
|
212
|
+
print_startup_banner(
|
|
213
|
+
source=source,
|
|
214
|
+
root_dir=root_dir,
|
|
215
|
+
url=url,
|
|
216
|
+
open_browser=open_browser,
|
|
217
|
+
python_reload=python_reload,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if open_browser:
|
|
221
|
+
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
|
222
|
+
|
|
223
|
+
if python_reload:
|
|
224
|
+
if app_factory_import is None:
|
|
225
|
+
raise ValueError("app_factory_import is required when Python reload is enabled")
|
|
226
|
+
with temporary_env(dict(env_updates or {})):
|
|
227
|
+
run_python_reloading_server(app_factory_import, host=host, port=port)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if application is None:
|
|
231
|
+
raise ValueError("application is required when Python reload is disabled")
|
|
232
|
+
|
|
233
|
+
server = create_server(application, host=host, port=port)
|
|
234
|
+
run_server(server)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.default
|
|
238
|
+
def serve(
|
|
239
|
+
path: Annotated[
|
|
240
|
+
Path,
|
|
241
|
+
Parameter(
|
|
242
|
+
converter=_validate_target,
|
|
243
|
+
help="Markdown file or directory to serve.",
|
|
244
|
+
),
|
|
245
|
+
] = Path("."),
|
|
246
|
+
/,
|
|
247
|
+
*,
|
|
248
|
+
host: Annotated[str, Parameter(help="Host interface to bind.")] = DEFAULT_HOST,
|
|
249
|
+
port: Annotated[int, Parameter(help="Port to listen on.")] = DEFAULT_PORT,
|
|
250
|
+
open_browser: Annotated[
|
|
251
|
+
bool,
|
|
252
|
+
Parameter(name="--open", help="Open the app in your default browser after the server starts."),
|
|
253
|
+
] = True,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Serve GitHub-flavored markdown from a file or directory."""
|
|
256
|
+
config = build_config(path)
|
|
257
|
+
site = build_file_site(config)
|
|
258
|
+
serve_application(
|
|
259
|
+
None if python_reload_enabled() else create_app(site),
|
|
260
|
+
source=str(config.source),
|
|
261
|
+
root_dir=str(config.root_dir),
|
|
262
|
+
host=host,
|
|
263
|
+
port=port,
|
|
264
|
+
open_browser=open_browser,
|
|
265
|
+
app_factory_import="markserv.cli:create_app_from_env",
|
|
266
|
+
env_updates={TARGET_ENV_VAR: str(config.source)},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def create_app_from_env() -> Any:
|
|
271
|
+
target = os.environ.get(TARGET_ENV_VAR)
|
|
272
|
+
if not target:
|
|
273
|
+
raise RuntimeError(f"{TARGET_ENV_VAR} must be set when Python reload is enabled")
|
|
274
|
+
config = build_config(Path(target))
|
|
275
|
+
return create_app(build_file_site(config))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main(argv: list[str] | None = None) -> None:
|
|
279
|
+
app(tokens=argv)
|
markserv/demo.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from cyclopts import App, Parameter
|
|
6
|
+
from cyclopts.help import PlainFormatter
|
|
7
|
+
|
|
8
|
+
from .app import create_app
|
|
9
|
+
from .cli import DEFAULT_HOST, DEFAULT_PORT, python_reload_enabled, serve_application
|
|
10
|
+
from .site import SyntheticSite
|
|
11
|
+
|
|
12
|
+
DEMO_DOCUMENTS = {
|
|
13
|
+
"README.md": """# markserv demo
|
|
14
|
+
|
|
15
|
+
Welcome to the built-in demo site.
|
|
16
|
+
|
|
17
|
+
This sample tree exists so you can quickly try the renderer, sidebar navigation, live reload, and theme control.
|
|
18
|
+
|
|
19
|
+
## Try these pages
|
|
20
|
+
|
|
21
|
+
- [Quickstart](guides/quickstart.md)
|
|
22
|
+
- [GitHub-flavored markdown examples](guides/features/gfm.md)
|
|
23
|
+
- [Nested navigation](guides/nested/deep-dive.md)
|
|
24
|
+
- [Reference notes](reference/notes.md)
|
|
25
|
+
|
|
26
|
+
## What to try
|
|
27
|
+
|
|
28
|
+
- Toggle between **system**, **light**, and **dark** themes.
|
|
29
|
+
- Follow links between nested folders.
|
|
30
|
+
- Resize the window to see the responsive layout.
|
|
31
|
+
|
|
32
|
+
> markserv is meant to feel nice for local docs, READMEs, and note collections.
|
|
33
|
+
""",
|
|
34
|
+
"guides/quickstart.md": """# Quickstart
|
|
35
|
+
|
|
36
|
+
This page gives you a quick way to verify the basics.
|
|
37
|
+
|
|
38
|
+
## Checklist
|
|
39
|
+
|
|
40
|
+
- [x] Markdown is rendered with GitHub-style formatting
|
|
41
|
+
- [x] Sidebar navigation is generated from nested folders
|
|
42
|
+
- [x] Theme choice is stored in browser storage
|
|
43
|
+
- [x] Synthetic demo content renders without touching the filesystem
|
|
44
|
+
|
|
45
|
+
## Code block
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
root = Path("docs")
|
|
51
|
+
print(root.resolve())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Table
|
|
55
|
+
|
|
56
|
+
| Feature | Status |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| Live preview | Ready |
|
|
59
|
+
| Sidebar nav | Ready |
|
|
60
|
+
| Theme picker | Ready |
|
|
61
|
+
|
|
62
|
+
Continue to the [feature examples](features/gfm.md).
|
|
63
|
+
""",
|
|
64
|
+
"guides/features/gfm.md": """# GitHub-flavored markdown examples
|
|
65
|
+
|
|
66
|
+
This page exercises a few GFM features.
|
|
67
|
+
|
|
68
|
+
## Formatting
|
|
69
|
+
|
|
70
|
+
You can render **bold text**, *italic text*, ~~strikethrough~~, and `inline code`.
|
|
71
|
+
|
|
72
|
+
## Blockquote
|
|
73
|
+
|
|
74
|
+
> Markdown previews should be quick to open and pleasant to read.
|
|
75
|
+
>
|
|
76
|
+
> — local docs enjoyer
|
|
77
|
+
|
|
78
|
+
## Task list
|
|
79
|
+
|
|
80
|
+
- [x] Tables
|
|
81
|
+
- [x] Fenced code blocks
|
|
82
|
+
- [x] Blockquotes
|
|
83
|
+
- [x] Nested navigation
|
|
84
|
+
|
|
85
|
+
## Ordered list
|
|
86
|
+
|
|
87
|
+
1. Open the demo.
|
|
88
|
+
2. Change the selected page.
|
|
89
|
+
3. Explore the nested tree.
|
|
90
|
+
|
|
91
|
+
Back to the [demo home](../../README.md).
|
|
92
|
+
""",
|
|
93
|
+
"guides/nested/deep-dive.md": """# Deep dive
|
|
94
|
+
|
|
95
|
+
This file lives in a nested folder so you can inspect sidebar behavior.
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
Nested folders are shown as expandable sections in the sidebar.
|
|
100
|
+
|
|
101
|
+
### Another level
|
|
102
|
+
|
|
103
|
+
The current page should stay highlighted while its parent folders remain open.
|
|
104
|
+
|
|
105
|
+
See the [reference notes](../../reference/notes.md) for a simple cross-link.
|
|
106
|
+
""",
|
|
107
|
+
"reference/notes.md": """# Reference notes
|
|
108
|
+
|
|
109
|
+
A small page for cross-link testing.
|
|
110
|
+
|
|
111
|
+
## Relative links
|
|
112
|
+
|
|
113
|
+
- [Back home](../README.md)
|
|
114
|
+
- [Quickstart](../guides/quickstart.md)
|
|
115
|
+
- [Deep dive](../guides/nested/deep-dive.md)
|
|
116
|
+
|
|
117
|
+
## Inline HTML
|
|
118
|
+
|
|
119
|
+
GitHub-flavored markdown rendering should also tolerate small inline HTML snippets like <kbd>Ctrl</kbd> + <kbd>C</kbd>.
|
|
120
|
+
""",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
__all__ = ["DEFAULT_HOST", "DEFAULT_PORT", "build_demo_site", "create_demo_app", "main", "serve_demo"]
|
|
124
|
+
|
|
125
|
+
app = App(
|
|
126
|
+
name="markserv.demo",
|
|
127
|
+
help="Serve the built-in synthetic markdown demo.",
|
|
128
|
+
help_formatter=PlainFormatter(),
|
|
129
|
+
result_action="return_value",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_demo_site() -> SyntheticSite:
|
|
134
|
+
return SyntheticSite(
|
|
135
|
+
name="markserv demo",
|
|
136
|
+
root_label="built-in demo",
|
|
137
|
+
documents=DEMO_DOCUMENTS,
|
|
138
|
+
default_doc="README.md",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_demo_app() -> object:
|
|
143
|
+
return create_app(build_demo_site())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def serve_demo(*, host: str, port: int, open_browser: bool) -> None:
|
|
147
|
+
site = build_demo_site()
|
|
148
|
+
serve_application(
|
|
149
|
+
None if python_reload_enabled() else create_app(site),
|
|
150
|
+
source="markserv demo",
|
|
151
|
+
root_dir=site.root_label,
|
|
152
|
+
host=host,
|
|
153
|
+
port=port,
|
|
154
|
+
open_browser=open_browser,
|
|
155
|
+
app_factory_import="markserv.demo:create_demo_app",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.default
|
|
160
|
+
def serve(
|
|
161
|
+
*,
|
|
162
|
+
host: Annotated[str, Parameter(help="Host interface to bind.")] = DEFAULT_HOST,
|
|
163
|
+
port: Annotated[int, Parameter(help="Port to listen on.")] = DEFAULT_PORT,
|
|
164
|
+
open_browser: Annotated[
|
|
165
|
+
bool,
|
|
166
|
+
Parameter(name="--open", help="Open the app in your default browser after the server starts."),
|
|
167
|
+
] = True,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Serve the built-in synthetic markdown demo."""
|
|
170
|
+
serve_demo(host=host, port=port, open_browser=open_browser)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main(argv: list[str] | None = None) -> None:
|
|
174
|
+
app(tokens=argv)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
main()
|
markserv/icons.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Generate per-page favicon PNGs using a Clifford strange attractor.
|
|
2
|
+
|
|
3
|
+
Zero external dependencies -- uses only stdlib (hashlib, math, struct, zlib).
|
|
4
|
+
Each page's content hash maps to a unique attractor that produces an
|
|
5
|
+
organic, visually distinct icon.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import math
|
|
12
|
+
import struct
|
|
13
|
+
import zlib
|
|
14
|
+
|
|
15
|
+
# Curated Clifford attractor parameters known to produce rich forms.
|
|
16
|
+
_GOOD_PARAMS: list[tuple[float, float, float, float]] = [
|
|
17
|
+
(1.5, -1.8, 1.6, 0.9),
|
|
18
|
+
(-1.7, 1.8, -0.9, -1.4),
|
|
19
|
+
(-1.7, 1.3, -0.1, -1.21),
|
|
20
|
+
(-1.4, 1.6, 1.0, 0.7),
|
|
21
|
+
(1.7, 1.7, 0.6, 1.2),
|
|
22
|
+
(-1.9, -1.9, -1.9, -1.0),
|
|
23
|
+
(1.8, -1.5, 1.4, -0.8),
|
|
24
|
+
(-1.2, 1.9, 0.3, -1.5),
|
|
25
|
+
(1.1, -1.3, 1.7, -1.8),
|
|
26
|
+
(-1.8, -1.0, -1.6, 0.6),
|
|
27
|
+
(1.6, -0.6, -1.2, 1.6),
|
|
28
|
+
(-1.5, 1.4, 1.1, -1.3),
|
|
29
|
+
(1.3, 1.7, -0.5, -1.6),
|
|
30
|
+
(-0.8, 1.9, -1.7, 1.1),
|
|
31
|
+
(1.9, -1.1, 0.8, -1.7),
|
|
32
|
+
(-1.6, -1.4, 1.8, 0.4),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_COLOR_STOPS_T = (0.00, 0.05, 0.15, 0.30, 0.50, 0.65, 0.80, 0.92, 1.00)
|
|
36
|
+
_COLOR_STOPS_C = (
|
|
37
|
+
(13, 17, 23),
|
|
38
|
+
(20, 30, 60),
|
|
39
|
+
(40, 70, 140),
|
|
40
|
+
(70, 130, 230),
|
|
41
|
+
(100, 110, 255),
|
|
42
|
+
(150, 100, 255),
|
|
43
|
+
(200, 150, 255),
|
|
44
|
+
(240, 220, 255),
|
|
45
|
+
(255, 245, 250),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _params_from_hash(digest: bytes, attempt: int = 0) -> tuple[float, float, float, float]:
|
|
50
|
+
idx = (digest[0] + attempt) % len(_GOOD_PARAMS)
|
|
51
|
+
base = _GOOD_PARAMS[idx]
|
|
52
|
+
return tuple(base[i] + (digest[i + 1] / 255.0 - 0.5) * 0.16 for i in range(4)) # type: ignore[return-value]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _hue_shift_from_hash(digest: bytes) -> float:
|
|
56
|
+
return (digest[8] / 255.0) * 0.4 - 0.2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clifford_density(
|
|
60
|
+
a: float,
|
|
61
|
+
b: float,
|
|
62
|
+
c: float,
|
|
63
|
+
d: float,
|
|
64
|
+
res: int,
|
|
65
|
+
n_points: int,
|
|
66
|
+
) -> list[list[int]]:
|
|
67
|
+
sin, cos = math.sin, math.cos
|
|
68
|
+
x, y = 0.1, 0.1
|
|
69
|
+
|
|
70
|
+
# Warmup + bounds
|
|
71
|
+
xs, ys = [], []
|
|
72
|
+
for _ in range(500):
|
|
73
|
+
x, y = sin(a * y) + c * cos(a * x), sin(b * x) + d * cos(b * y)
|
|
74
|
+
xs.append(x)
|
|
75
|
+
ys.append(y)
|
|
76
|
+
|
|
77
|
+
x_min, x_max = min(xs), max(xs)
|
|
78
|
+
y_min, y_max = min(ys), max(ys)
|
|
79
|
+
span = max(x_max - x_min, y_max - y_min)
|
|
80
|
+
if span < 0.01:
|
|
81
|
+
span = 4.0
|
|
82
|
+
pad = span * 0.12
|
|
83
|
+
span += 2 * pad
|
|
84
|
+
cx, cy = (x_min + x_max) / 2, (y_min + y_max) / 2
|
|
85
|
+
x_lo, y_lo = cx - span / 2, cy - span / 2
|
|
86
|
+
scale = (res - 1) / span
|
|
87
|
+
|
|
88
|
+
grid = [[0] * res for _ in range(res)]
|
|
89
|
+
for _ in range(n_points):
|
|
90
|
+
x, y = sin(a * y) + c * cos(a * x), sin(b * x) + d * cos(b * y)
|
|
91
|
+
bx = int((x - x_lo) * scale)
|
|
92
|
+
by = int((y - y_lo) * scale)
|
|
93
|
+
if 0 <= bx < res and 0 <= by < res:
|
|
94
|
+
grid[by][bx] += 1
|
|
95
|
+
|
|
96
|
+
return grid
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _grid_is_interesting(grid: list[list[int]], res: int) -> bool:
|
|
100
|
+
filled = sum(1 for row in grid for v in row if v > 0)
|
|
101
|
+
return filled > res * res * 0.05 # at least 5% of pixels hit
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _colorize_rgba(grid: list[list[int]], hue_shift: float) -> list[list[tuple[int, int, int, int]]]:
|
|
105
|
+
"""Colorize with alpha derived from density. Background is fully transparent."""
|
|
106
|
+
max_raw = max(max(row) for row in grid)
|
|
107
|
+
if max_raw == 0:
|
|
108
|
+
return [[(0, 0, 0, 0)] * len(grid[0]) for _ in grid]
|
|
109
|
+
|
|
110
|
+
log_max = math.log1p(max_raw)
|
|
111
|
+
stops_t = _COLOR_STOPS_T
|
|
112
|
+
stops_c = _COLOR_STOPS_C
|
|
113
|
+
|
|
114
|
+
def lerp_color(t: float) -> tuple[int, int, int]:
|
|
115
|
+
# Skip the first two dark stops -- start from visible blue
|
|
116
|
+
t = max(0.0, min(1.0, t + hue_shift * t))
|
|
117
|
+
for i in range(len(stops_t) - 1):
|
|
118
|
+
if t <= stops_t[i + 1]:
|
|
119
|
+
t0, t1 = stops_t[i], stops_t[i + 1]
|
|
120
|
+
c0, c1 = stops_c[i], stops_c[i + 1]
|
|
121
|
+
f = (t - t0) / (t1 - t0) if t1 > t0 else 0.0
|
|
122
|
+
return (
|
|
123
|
+
min(255, max(0, int(c0[0] + (c1[0] - c0[0]) * f))),
|
|
124
|
+
min(255, max(0, int(c0[1] + (c1[1] - c0[1]) * f))),
|
|
125
|
+
min(255, max(0, int(c0[2] + (c1[2] - c0[2]) * f))),
|
|
126
|
+
)
|
|
127
|
+
return stops_c[-1]
|
|
128
|
+
|
|
129
|
+
result: list[list[tuple[int, int, int, int]]] = []
|
|
130
|
+
for row in grid:
|
|
131
|
+
rgba_row: list[tuple[int, int, int, int]] = []
|
|
132
|
+
for v in row:
|
|
133
|
+
if v == 0:
|
|
134
|
+
rgba_row.append((0, 0, 0, 0))
|
|
135
|
+
else:
|
|
136
|
+
t = math.log1p(v) / log_max
|
|
137
|
+
r, g, b = lerp_color(t)
|
|
138
|
+
# Alpha proportional to density -- faint wisps are translucent, hot spots are opaque
|
|
139
|
+
a = min(255, int(t * 320))
|
|
140
|
+
rgba_row.append((r, g, b, a))
|
|
141
|
+
result.append(rgba_row)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _encode_png_rgba(pixels: list[list[tuple[int, int, int, int]]], width: int, height: int) -> bytes:
|
|
146
|
+
"""Encode RGBA PNG using only stdlib."""
|
|
147
|
+
|
|
148
|
+
def chunk(ctype: bytes, data: bytes) -> bytes:
|
|
149
|
+
c = ctype + data
|
|
150
|
+
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
|
151
|
+
|
|
152
|
+
raw = bytearray()
|
|
153
|
+
for row in pixels:
|
|
154
|
+
raw.append(0) # filter: none
|
|
155
|
+
for r, g, b, a in row:
|
|
156
|
+
raw.extend((r, g, b, a))
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
b"\x89PNG\r\n\x1a\n"
|
|
160
|
+
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
|
|
161
|
+
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9))
|
|
162
|
+
+ chunk(b"IEND", b"")
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def generate_favicon(content: str, res: int = 48, n_points: int = 150_000) -> bytes:
|
|
167
|
+
"""Generate a unique favicon PNG from content. Pure stdlib, ~30ms at 48px."""
|
|
168
|
+
digest = hashlib.sha256(content.encode()).digest()
|
|
169
|
+
hue_shift = _hue_shift_from_hash(digest)
|
|
170
|
+
|
|
171
|
+
grid: list[list[int]] | None = None
|
|
172
|
+
for attempt in range(len(_GOOD_PARAMS)):
|
|
173
|
+
a, b, c, d = _params_from_hash(digest, attempt)
|
|
174
|
+
grid = _clifford_density(a, b, c, d, res, n_points)
|
|
175
|
+
if _grid_is_interesting(grid, res):
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
if grid is None:
|
|
179
|
+
grid = [[0] * res for _ in range(res)]
|
|
180
|
+
|
|
181
|
+
pixels = _colorize_rgba(grid, hue_shift)
|
|
182
|
+
return _encode_png_rgba(pixels, res, res)
|