streamlit-mini-toolkit 0.1.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.
Files changed (42) hide show
  1. mini_toolkit/__init__.py +3 -0
  2. mini_toolkit/__main__.py +3 -0
  3. mini_toolkit/app.py +111 -0
  4. mini_toolkit/cli.py +40 -0
  5. mini_toolkit/core/__init__.py +1 -0
  6. mini_toolkit/core/net.py +56 -0
  7. mini_toolkit/core/pdf.py +12 -0
  8. mini_toolkit/core/registry.py +75 -0
  9. mini_toolkit/core/ui.py +90 -0
  10. mini_toolkit/streamlit_config.toml +16 -0
  11. mini_toolkit/style/style.css +171 -0
  12. mini_toolkit/tools/__init__.py +1 -0
  13. mini_toolkit/tools/background_remover.py +65 -0
  14. mini_toolkit/tools/change_tracker.py +90 -0
  15. mini_toolkit/tools/chat_alert.py +42 -0
  16. mini_toolkit/tools/color_picker.py +40 -0
  17. mini_toolkit/tools/document_converter.py +121 -0
  18. mini_toolkit/tools/file_compressor.py +78 -0
  19. mini_toolkit/tools/github_trending.py +53 -0
  20. mini_toolkit/tools/hash_generator.py +43 -0
  21. mini_toolkit/tools/image_converter.py +63 -0
  22. mini_toolkit/tools/image_downloader.py +67 -0
  23. mini_toolkit/tools/image_enhancer.py +54 -0
  24. mini_toolkit/tools/image_watermarker.py +63 -0
  25. mini_toolkit/tools/ocr.py +93 -0
  26. mini_toolkit/tools/page_speed.py +45 -0
  27. mini_toolkit/tools/password_checker.py +53 -0
  28. mini_toolkit/tools/pdf_tools.py +83 -0
  29. mini_toolkit/tools/qr_code.py +83 -0
  30. mini_toolkit/tools/resume_builder.py +385 -0
  31. mini_toolkit/tools/seo_health.py +64 -0
  32. mini_toolkit/tools/signature.py +72 -0
  33. mini_toolkit/tools/ssl_checker.py +68 -0
  34. mini_toolkit/tools/text_to_speech.py +34 -0
  35. mini_toolkit/tools/text_utilities.py +36 -0
  36. mini_toolkit/tools/url_shortener.py +29 -0
  37. mini_toolkit/tools/website_status.py +39 -0
  38. streamlit_mini_toolkit-0.1.0.dist-info/METADATA +151 -0
  39. streamlit_mini_toolkit-0.1.0.dist-info/RECORD +42 -0
  40. streamlit_mini_toolkit-0.1.0.dist-info/WHEEL +4 -0
  41. streamlit_mini_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  42. streamlit_mini_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Mini Toolkit — everyday utilities in one Streamlit app."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
mini_toolkit/app.py ADDED
@@ -0,0 +1,111 @@
1
+ """Mini Toolkit — a collection of everyday utilities in one Streamlit app.
2
+
3
+ Run locally with: streamlit run app.py
4
+ """
5
+ import html
6
+ import traceback
7
+
8
+ import streamlit as st
9
+
10
+ from core.registry import CATEGORIES, TOOLS, TOOLS_BY_SLUG, Tool, tools_in
11
+ from core.ui import footer, load_css, tool_header
12
+
13
+ st.set_page_config(page_title="Mini Toolkit", page_icon="🧰", layout="wide", initial_sidebar_state="collapsed")
14
+ load_css()
15
+
16
+
17
+ def open_tool(slug=None) -> None:
18
+ if slug:
19
+ st.query_params["tool"] = slug
20
+ else:
21
+ st.query_params.clear()
22
+
23
+
24
+ def render_sidebar(active=None) -> None:
25
+ with st.sidebar:
26
+ st.button("🏠 All tools", on_click=open_tool, width="stretch", type="primary" if active is None else "secondary")
27
+ for category in CATEGORIES:
28
+ st.markdown(f"<div class='nav-heading'>{category}</div>", unsafe_allow_html=True)
29
+ for tool in tools_in(category):
30
+ st.button(
31
+ f"{tool.icon} {tool.name}",
32
+ key=f"nav_{tool.slug}",
33
+ on_click=open_tool,
34
+ args=(tool.slug,),
35
+ width="stretch",
36
+ type="primary" if active and active.slug == tool.slug else "tertiary",
37
+ )
38
+
39
+
40
+ def tool_card(tool: Tool) -> None:
41
+ with st.container(border=True):
42
+ st.markdown(
43
+ f"""
44
+ <div class="tool-card">
45
+ <div class="tool-card-icon">{tool.icon}</div>
46
+ <div class="tool-card-title">{html.escape(tool.name)}</div>
47
+ <div class="tool-card-desc">{html.escape(tool.description)}</div>
48
+ </div>
49
+ """,
50
+ unsafe_allow_html=True,
51
+ )
52
+ st.button("Open", key=f"open_{tool.slug}", on_click=open_tool, args=(tool.slug,), width="stretch")
53
+
54
+
55
+ def render_home() -> None:
56
+ st.markdown(
57
+ f"""
58
+ <section class="hero">
59
+ <span class="hero-badge">{len(TOOLS)} free tools · no sign-up</span>
60
+ <h1>Everyday tools, <span class="accent">one place.</span></h1>
61
+ <p>Edit images, convert documents, check websites and more — right in your browser.</p>
62
+ </section>
63
+ """,
64
+ unsafe_allow_html=True,
65
+ )
66
+
67
+ query = st.text_input("Search tools", placeholder="🔎 Search tools — e.g. pdf, qr, background", label_visibility="collapsed").strip().lower()
68
+ if query:
69
+ matches = [t for t in TOOLS if query in f"{t.name} {t.description} {t.category}".lower()]
70
+ if not matches:
71
+ st.info(f"No tools match “{query}”.")
72
+ return
73
+ sections = {"Search results": matches}
74
+ else:
75
+ sections = {category: tools_in(category) for category in CATEGORIES}
76
+
77
+ for title, tools in sections.items():
78
+ subtitle = CATEGORIES.get(title, f"{len(tools)} match{'es' if len(tools) != 1 else ''}")
79
+ st.markdown(f"<div class='category-heading'><div class='category-title'>{title}</div><span>{subtitle}</span></div>", unsafe_allow_html=True)
80
+ # Build row by row so cards keep their order when columns stack on mobile.
81
+ for row in range(0, len(tools), 4):
82
+ for col, tool in zip(st.columns(4), tools[row:row + 4]):
83
+ with col:
84
+ tool_card(tool)
85
+
86
+
87
+ def render_tool(tool: Tool) -> None:
88
+ st.button("← All tools", on_click=open_tool, key="back_home", type="tertiary")
89
+ tool_header(tool.icon, tool.name, tool.description)
90
+ try:
91
+ tool.load()()
92
+ except Exception as exc: # keep one broken tool from blanking the whole page
93
+ st.error(f"Something went wrong in {tool.name}: {exc}")
94
+ with st.expander("Technical details"):
95
+ st.code(traceback.format_exc())
96
+
97
+
98
+ def main() -> None:
99
+ slug = st.query_params.get("tool")
100
+ tool = TOOLS_BY_SLUG.get(slug) if slug else None
101
+ render_sidebar(tool)
102
+ if slug and not tool:
103
+ st.warning(f"Unknown tool “{slug}”. Showing all tools instead.")
104
+ if tool:
105
+ render_tool(tool)
106
+ else:
107
+ render_home()
108
+ footer()
109
+
110
+
111
+ main()
mini_toolkit/cli.py ADDED
@@ -0,0 +1,40 @@
1
+ """``mini-toolkit`` command: launches the bundled Streamlit app."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ HERE = Path(__file__).resolve().parent
8
+
9
+
10
+ def _bundled_config_flags() -> list[str]:
11
+ """Turn the bundled Streamlit config into CLI flags.
12
+
13
+ Streamlit only reads ``.streamlit/config.toml`` from the current directory,
14
+ so an installed copy would otherwise lose its theme and upload limit.
15
+ """
16
+ path = HERE / "streamlit_config.toml"
17
+ if not path.exists():
18
+ return []
19
+ try:
20
+ import tomllib # Python 3.11+
21
+ data = tomllib.loads(path.read_text("utf-8"))
22
+ except ModuleNotFoundError:
23
+ import toml # installed with Streamlit
24
+
25
+ data = toml.loads(path.read_text("utf-8"))
26
+ return [f"--{section}.{key}={value}" for section, opts in data.items() for key, value in opts.items()]
27
+
28
+
29
+ def main() -> None:
30
+ from streamlit.web import cli as stcli
31
+
32
+ app = HERE / "app.py"
33
+ if not app.exists():
34
+ sys.exit("mini-toolkit: app.py not found. Run `streamlit run app.py` from a source checkout instead.")
35
+ sys.argv = ["streamlit", "run", str(app), *_bundled_config_flags(), *sys.argv[1:]]
36
+ sys.exit(stcli.main())
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1 @@
1
+ """Shared building blocks for the Mini Toolkit app."""
@@ -0,0 +1,56 @@
1
+ """HTTP helpers for the web tools.
2
+
3
+ All outbound requests go through :func:`fetch`, which normalises the URL and
4
+ refuses to reach private or loopback addresses, so a publicly hosted copy of the
5
+ app can't be used to probe the host's internal network.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import socket
11
+ from urllib.parse import urlparse
12
+
13
+ import requests
14
+
15
+ USER_AGENT = "Mozilla/5.0 (compatible; MiniToolkit/2.0; +https://github.com/sarif-mia/mini-toolkit)"
16
+ DEFAULT_TIMEOUT = 10
17
+
18
+
19
+ class UnsafeURLError(ValueError):
20
+ pass
21
+
22
+
23
+ def normalize_url(url: str) -> str:
24
+ url = url.strip()
25
+ if url and not url.startswith(("http://", "https://")):
26
+ url = "https://" + url
27
+ return url
28
+
29
+
30
+ def ensure_public_url(url: str) -> str:
31
+ """Return the normalised URL, or raise if it is malformed or points at a private address."""
32
+ url = normalize_url(url)
33
+ host = urlparse(url).hostname
34
+ if not host:
35
+ raise UnsafeURLError("Please enter a valid URL, e.g. https://example.com")
36
+ try:
37
+ infos = socket.getaddrinfo(host, None)
38
+ except socket.gaierror:
39
+ raise UnsafeURLError(f"Could not resolve host '{host}'.")
40
+ for info in infos:
41
+ ip = ipaddress.ip_address(info[4][0])
42
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
43
+ raise UnsafeURLError(f"'{host}' resolves to a private address and can't be checked.")
44
+ return url
45
+
46
+
47
+ def fetch(url: str, timeout: float = DEFAULT_TIMEOUT, max_redirects: int = 5, **kwargs) -> requests.Response:
48
+ """GET a public URL, re-validating every redirect hop."""
49
+ headers = {"User-Agent": USER_AGENT, **kwargs.pop("headers", {})}
50
+ for _ in range(max_redirects + 1):
51
+ url = ensure_public_url(url)
52
+ response = requests.get(url, timeout=timeout, headers=headers, allow_redirects=False, **kwargs)
53
+ if not response.is_redirect:
54
+ return response
55
+ url = requests.compat.urljoin(url, response.headers["Location"])
56
+ raise requests.TooManyRedirects(f"Exceeded {max_redirects} redirects.")
@@ -0,0 +1,12 @@
1
+ """Helpers for generating PDFs with fpdf2's built-in fonts."""
2
+
3
+ # Built-in PDF fonts only cover Latin-1, so map common typographic characters
4
+ # to ASCII equivalents and replace anything else instead of crashing.
5
+ _TYPOGRAPHIC = str.maketrans({
6
+ "–": "-", "—": "-", "‘": "'", "’": "'",
7
+ "“": '"', "”": '"', "•": "*", "…": "...", " ": " ",
8
+ })
9
+
10
+
11
+ def pdf_safe(text: str) -> str:
12
+ return text.translate(_TYPOGRAPHIC).encode("latin-1", errors="replace").decode("latin-1")
@@ -0,0 +1,75 @@
1
+ """Central registry of every tool in the toolkit.
2
+
3
+ Each tool points at a module in ``tools/`` that exposes a ``render()`` function.
4
+ Modules are imported lazily, so a missing optional dependency only affects the
5
+ tool that needs it instead of taking down the whole app.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ from dataclasses import dataclass
11
+ from typing import Callable, Dict, List
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Tool:
16
+ slug: str
17
+ name: str
18
+ icon: str
19
+ description: str
20
+ category: str
21
+ module: str
22
+
23
+ def load(self) -> Callable[[], None]:
24
+ return importlib.import_module(f"tools.{self.module}").render
25
+
26
+
27
+ IMAGE = "Image"
28
+ DOCUMENT = "Documents"
29
+ TEXT = "Text & Security"
30
+ WEB = "Web & Developer"
31
+
32
+ CATEGORIES: Dict[str, str] = {
33
+ IMAGE: "Edit, convert and analyse images.",
34
+ DOCUMENT: "Convert, merge and create documents.",
35
+ TEXT: "Transform text, hashes and passwords.",
36
+ WEB: "Inspect websites and automate small tasks.",
37
+ }
38
+
39
+ TOOLS: List[Tool] = [
40
+ # Image
41
+ Tool("background-remover", "Background Remover", "🪄", "Remove image backgrounds with AI models.", IMAGE, "background_remover"),
42
+ Tool("image-converter", "Image Converter", "🔁", "Convert images between PNG, JPG, WEBP, BMP and TIFF.", IMAGE, "image_converter"),
43
+ Tool("image-enhancer", "Image Enhancer", "✨", "Adjust brightness, contrast, colour and sharpness.", IMAGE, "image_enhancer"),
44
+ Tool("image-watermarker", "Image Watermarker", "💧", "Stamp a text watermark onto your images.", IMAGE, "image_watermarker"),
45
+ Tool("color-picker", "Image Color Picker", "🎨", "Click an image to get exact HEX and RGB values.", IMAGE, "color_picker"),
46
+ Tool("qr-code", "QR Code Generator", "🔳", "Create QR codes for links, contacts and Wi-Fi.", IMAGE, "qr_code"),
47
+ Tool("ocr", "OCR Text Extractor", "📝", "Extract text from images and screenshots.", IMAGE, "ocr"),
48
+ # Documents
49
+ Tool("document-converter", "Document Converter", "📄", "Convert between DOCX, PDF and TXT.", DOCUMENT, "document_converter"),
50
+ Tool("pdf-tools", "PDF Merge & Split", "📑", "Combine PDFs or extract pages from one.", DOCUMENT, "pdf_tools"),
51
+ Tool("file-compressor", "File Compressor", "🗜️", "Zip files or shrink image file sizes.", DOCUMENT, "file_compressor"),
52
+ Tool("resume-builder", "Resume Builder", "🧾", "Build a resume and cover letter as PDF or DOCX.", DOCUMENT, "resume_builder"),
53
+ Tool("email-signature", "Email Signature", "✒️", "Design an HTML email signature.", DOCUMENT, "signature"),
54
+ # Text & Security
55
+ Tool("text-utilities", "Text Utilities", "🔤", "Count words, change case and clean up text.", TEXT, "text_utilities"),
56
+ Tool("text-to-speech", "Text to Speech", "🔊", "Turn text into downloadable MP3 audio.", TEXT, "text_to_speech"),
57
+ Tool("hash-generator", "Hash Generator", "#️⃣", "Compute MD5 and SHA hashes of text or files.", TEXT, "hash_generator"),
58
+ Tool("password-checker", "Password Checker", "🛡️", "Score password strength and get tips.", TEXT, "password_checker"),
59
+ # Web & Developer
60
+ Tool("website-status", "Website Status", "🌐", "Check whether one or many sites are up.", WEB, "website_status"),
61
+ Tool("page-speed", "Page Speed Checker", "⚡", "Measure server response and download time.", WEB, "page_speed"),
62
+ Tool("ssl-checker", "SSL Certificate Checker", "🔒", "Inspect certificate validity and expiry.", WEB, "ssl_checker"),
63
+ Tool("seo-health", "SEO Health Check", "🔍", "Audit titles, meta tags and headings.", WEB, "seo_health"),
64
+ Tool("image-downloader", "Website Image Downloader", "🖼️", "Collect every image on a page as a ZIP.", WEB, "image_downloader"),
65
+ Tool("url-shortener", "URL Shortener", "🔗", "Shorten long links with TinyURL.", WEB, "url_shortener"),
66
+ Tool("change-tracker", "Website Change Tracker", "🔔", "Diff a page against its last snapshot.", WEB, "change_tracker"),
67
+ Tool("github-trending", "GitHub Trending", "📈", "Discover fast-rising GitHub repositories.", WEB, "github_trending"),
68
+ Tool("chat-alert", "Chat Alert Bot", "💬", "Send messages to Slack or Teams webhooks.", WEB, "chat_alert"),
69
+ ]
70
+
71
+ TOOLS_BY_SLUG: Dict[str, Tool] = {tool.slug: tool for tool in TOOLS}
72
+
73
+
74
+ def tools_in(category: str) -> List[Tool]:
75
+ return [tool for tool in TOOLS if tool.category == category]
@@ -0,0 +1,90 @@
1
+ """Reusable Streamlit UI helpers shared across tools."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ import io
6
+ import zipfile
7
+ from pathlib import Path
8
+ from typing import Iterable, List, Optional, Tuple
9
+
10
+ import streamlit as st
11
+
12
+ MAX_UPLOAD_MB = 10
13
+ STYLE_PATH = Path(__file__).resolve().parent.parent / "style" / "style.css"
14
+ GITHUB_URL = "https://github.com/sarif-mia/mini-toolkit"
15
+
16
+
17
+ def load_css() -> None:
18
+ st.markdown(f"<style>{STYLE_PATH.read_text(encoding='utf-8')}</style>", unsafe_allow_html=True)
19
+
20
+
21
+ def tool_header(icon: str, title: str, description: str) -> None:
22
+ st.markdown(
23
+ f"""
24
+ <div class="tool-header">
25
+ <div class="tool-header-icon">{icon}</div>
26
+ <div>
27
+ <h1>{html.escape(title)}</h1>
28
+ <p>{html.escape(description)}</p>
29
+ </div>
30
+ </div>
31
+ """,
32
+ unsafe_allow_html=True,
33
+ )
34
+
35
+
36
+ def section(title: str) -> None:
37
+ st.markdown(f"<div class='section-title'>{html.escape(title)}</div>", unsafe_allow_html=True)
38
+
39
+
40
+ def footer() -> None:
41
+ st.markdown(
42
+ f"""
43
+ <footer class="app-footer">
44
+ <span>Mini Toolkit · built by <a href="https://sarifmiia.netlify.app/" target="_blank" rel="noopener">Sarif</a></span>
45
+ <span class="footer-links">
46
+ <a href="{GITHUB_URL}" target="_blank" rel="noopener">GitHub</a>
47
+ <a href="https://www.linkedin.com/in/sarif-mia/" target="_blank" rel="noopener">LinkedIn</a>
48
+ <a href="mailto:sarif@lovegenbd.com">Contact</a>
49
+ </span>
50
+ </footer>
51
+ """,
52
+ unsafe_allow_html=True,
53
+ )
54
+
55
+
56
+ def sanitize_filename(name: str) -> str:
57
+ stem = "".join(c if c.isalnum() or c in "-_" else "_" for c in Path(name).stem)
58
+ return stem or "file"
59
+
60
+
61
+ def image_uploader(key: str, types: Iterable[str] = ("png", "jpg", "jpeg", "webp", "bmp", "tiff")) -> List:
62
+ """Multi-image uploader that drops files above the size limit with a warning."""
63
+ files = st.file_uploader(
64
+ f"Upload images (max {MAX_UPLOAD_MB} MB each)",
65
+ type=list(types),
66
+ accept_multiple_files=True,
67
+ key=key,
68
+ ) or []
69
+ accepted = []
70
+ for f in files:
71
+ if f.size > MAX_UPLOAD_MB * 1024 ** 2:
72
+ st.warning(f"{f.name} is {f.size / 1024 ** 2:.1f} MB and was skipped (limit {MAX_UPLOAD_MB} MB).")
73
+ else:
74
+ accepted.append(f)
75
+ return accepted
76
+
77
+
78
+ def build_zip(entries: Iterable[Tuple[str, bytes]]) -> bytes:
79
+ buffer = io.BytesIO()
80
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
81
+ for name, data in entries:
82
+ zf.writestr(name, data)
83
+ return buffer.getvalue()
84
+
85
+
86
+ def missing_dependency(package: str, extra: Optional[str] = None) -> None:
87
+ message = f"This tool needs the `{package}` package, which isn't installed on this server."
88
+ if extra:
89
+ message += f" {extra}"
90
+ st.error(message)
@@ -0,0 +1,16 @@
1
+ [theme]
2
+ base = "dark"
3
+ primaryColor = "#5B8CFF"
4
+ backgroundColor = "#0D1017"
5
+ secondaryBackgroundColor = "#161A23"
6
+ textColor = "#E7E9EE"
7
+ font = "sans serif"
8
+
9
+ [server]
10
+ maxUploadSize = 50
11
+
12
+ [browser]
13
+ gatherUsageStats = false
14
+
15
+ [client]
16
+ toolbarMode = "minimal"
@@ -0,0 +1,171 @@
1
+ /* Mini Toolkit — base colours come from .streamlit/config.toml; this file adds layout and components. */
2
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
3
+
4
+ :root {
5
+ --accent: #5B8CFF;
6
+ --accent-soft: rgba(91, 140, 255, 0.12);
7
+ --surface: #141821;
8
+ --surface-raised: #1A1F2B;
9
+ --border: #262C3A;
10
+ --text: #E7E9EE;
11
+ --muted: #8B93A7;
12
+ --radius: 14px;
13
+ }
14
+
15
+ html, body, .stApp, button, input, textarea, select {
16
+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif !important;
17
+ }
18
+
19
+ /* Hide Streamlit chrome that doesn't belong in a product UI */
20
+ #MainMenu, footer[class*="st-emotion"], [data-testid="stDecoration"] { display: none !important; }
21
+ .block-container { padding-top: 2rem; padding-bottom: 2rem; max-width: 1200px; }
22
+
23
+ /* ---------- Home hero ---------- */
24
+ .hero {
25
+ text-align: center;
26
+ padding: 3rem 1rem 2rem;
27
+ }
28
+ .hero-badge {
29
+ display: inline-block;
30
+ padding: 0.3rem 0.85rem;
31
+ border-radius: 999px;
32
+ background: var(--accent-soft);
33
+ color: var(--accent);
34
+ font-size: 0.82rem;
35
+ font-weight: 600;
36
+ letter-spacing: 0.02em;
37
+ margin-bottom: 1.1rem;
38
+ }
39
+ .hero h1 {
40
+ font-size: clamp(2rem, 5vw, 3.2rem);
41
+ font-weight: 800;
42
+ letter-spacing: -0.03em;
43
+ line-height: 1.1;
44
+ margin: 0 0 0.8rem;
45
+ padding: 0;
46
+ color: var(--text);
47
+ }
48
+ .hero .accent {
49
+ background: linear-gradient(90deg, #5B8CFF, #A47BFF);
50
+ -webkit-background-clip: text;
51
+ background-clip: text;
52
+ color: transparent;
53
+ }
54
+ .hero p {
55
+ color: var(--muted);
56
+ font-size: 1.1rem;
57
+ max-width: 560px;
58
+ margin: 0 auto;
59
+ }
60
+
61
+ /* ---------- Category sections ---------- */
62
+ .category-heading {
63
+ display: flex;
64
+ align-items: baseline;
65
+ gap: 0.75rem;
66
+ flex-wrap: wrap;
67
+ margin: 2.2rem 0 0.9rem;
68
+ padding-bottom: 0.6rem;
69
+ border-bottom: 1px solid var(--border);
70
+ }
71
+ .category-title {
72
+ font-size: 1.35rem !important;
73
+ font-weight: 700;
74
+ color: var(--text) !important;
75
+ margin: 0;
76
+ padding: 0 !important;
77
+ }
78
+ .category-heading span { color: var(--muted); font-size: 0.92rem; }
79
+
80
+ /* ---------- Tool cards ---------- */
81
+ [data-testid="stVerticalBlockBorderWrapper"]:has(.tool-card) {
82
+ background: var(--surface);
83
+ border-color: var(--border) !important;
84
+ border-radius: var(--radius) !important;
85
+ transition: border-color 0.15s ease, transform 0.15s ease;
86
+ }
87
+ [data-testid="stVerticalBlockBorderWrapper"]:has(.tool-card):hover {
88
+ border-color: var(--accent) !important;
89
+ transform: translateY(-2px);
90
+ }
91
+ .tool-card { min-height: 128px; }
92
+ .tool-card-icon {
93
+ width: 40px;
94
+ height: 40px;
95
+ display: grid;
96
+ place-items: center;
97
+ border-radius: 10px;
98
+ background: var(--accent-soft);
99
+ font-size: 1.3rem;
100
+ margin-bottom: 0.75rem;
101
+ }
102
+ .tool-card-title { font-weight: 650; font-size: 1rem; color: var(--text); margin-bottom: 0.3rem; }
103
+ .tool-card-desc { color: var(--muted); font-size: 0.88rem; line-height: 1.45; }
104
+
105
+ /* ---------- Tool page header ---------- */
106
+ .tool-header {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 1.1rem;
110
+ padding: 1.4rem 1.5rem;
111
+ margin: 0.4rem 0 1.6rem;
112
+ background: var(--surface);
113
+ border: 1px solid var(--border);
114
+ border-radius: var(--radius);
115
+ }
116
+ .tool-header-icon {
117
+ flex: none;
118
+ width: 56px;
119
+ height: 56px;
120
+ display: grid;
121
+ place-items: center;
122
+ border-radius: 14px;
123
+ background: var(--accent-soft);
124
+ font-size: 1.8rem;
125
+ }
126
+ .tool-header h1 { font-size: 1.6rem !important; font-weight: 750; margin: 0; padding: 0 !important; letter-spacing: -0.01em; }
127
+ .tool-header p { color: var(--muted); margin: 0.2rem 0 0; }
128
+
129
+ .section-title { font-weight: 650; font-size: 1.1rem; margin: 1.2rem 0 0.6rem; }
130
+
131
+ /* ---------- Sidebar ---------- */
132
+ [data-testid="stSidebar"] { border-right: 1px solid var(--border); }
133
+ .nav-heading {
134
+ color: var(--muted);
135
+ font-size: 0.72rem;
136
+ font-weight: 700;
137
+ text-transform: uppercase;
138
+ letter-spacing: 0.08em;
139
+ margin: 1.1rem 0 0.2rem;
140
+ }
141
+ [data-testid="stSidebar"] button[kind="tertiary"] { justify-content: flex-start; }
142
+
143
+ /* ---------- Components ---------- */
144
+ .stButton > button, .stDownloadButton > button { border-radius: 10px; font-weight: 600; }
145
+ .color-swatch { width: 100%; aspect-ratio: 1; border-radius: 12px; border: 1px solid var(--border); }
146
+ .signature-preview { padding: 1.25rem; border-radius: 10px; }
147
+ .signature-preview.light { background: #FFFFFF; border: 1px solid #E5E7EB; }
148
+ .signature-preview.dark { background: #1F2937; }
149
+
150
+ /* ---------- Footer ---------- */
151
+ .app-footer {
152
+ display: flex;
153
+ justify-content: space-between;
154
+ align-items: center;
155
+ flex-wrap: wrap;
156
+ gap: 0.75rem;
157
+ margin-top: 3.5rem;
158
+ padding: 1.25rem 0 0.5rem;
159
+ border-top: 1px solid var(--border);
160
+ color: var(--muted);
161
+ font-size: 0.88rem;
162
+ }
163
+ .app-footer a { color: var(--muted); text-decoration: none; }
164
+ .app-footer a:hover { color: var(--accent); }
165
+ .footer-links { display: flex; gap: 1.25rem; }
166
+
167
+ @media (max-width: 640px) {
168
+ .hero { padding-top: 1.5rem; }
169
+ .tool-header { padding: 1rem; }
170
+ .tool-header-icon { width: 44px; height: 44px; font-size: 1.4rem; }
171
+ }
@@ -0,0 +1 @@
1
+ """Tool modules. Each one exposes a ``render()`` function; see ``core.registry``."""
@@ -0,0 +1,65 @@
1
+ import io
2
+
3
+ import streamlit as st
4
+ from PIL import Image
5
+
6
+ from core.ui import build_zip, image_uploader, missing_dependency, sanitize_filename, section
7
+
8
+ MODELS = {
9
+ "General (best for most images)": "u2net",
10
+ "People / portraits": "u2net_human_seg",
11
+ "Clothing": "u2net_cloth_seg",
12
+ "Fast (lower quality)": "u2netp",
13
+ }
14
+
15
+
16
+ @st.cache_resource(show_spinner="Loading background removal model…")
17
+ def _session(model_name: str):
18
+ from rembg import new_session
19
+
20
+ # Force CPU so machines with onnxruntime-gpu but no CUDA don't error out.
21
+ return new_session(model_name, providers=["CPUExecutionProvider"])
22
+
23
+
24
+ def render() -> None:
25
+ try:
26
+ from rembg import remove
27
+ except ImportError:
28
+ missing_dependency("rembg")
29
+ return
30
+
31
+ model_label = st.selectbox("Model", list(MODELS))
32
+ trim = st.checkbox("Trim transparent edges", value=True)
33
+ files = image_uploader("bg_remover_upload", types=("png", "jpg", "jpeg", "webp"))
34
+ if not files:
35
+ st.info("Upload one or more images to get started.")
36
+ return
37
+
38
+ session = _session(MODELS[model_label])
39
+ section("Results")
40
+ outputs = []
41
+ progress = st.progress(0.0, text="Removing backgrounds…")
42
+ for i, f in enumerate(files, start=1):
43
+ try:
44
+ original = Image.open(f)
45
+ result = remove(original, session=session)
46
+ if trim and (bbox := result.getbbox()):
47
+ result = result.crop(bbox)
48
+ except Exception as exc:
49
+ st.error(f"Could not process {f.name}: {exc}")
50
+ continue
51
+ buf = io.BytesIO()
52
+ result.save(buf, format="PNG")
53
+ name = f"{sanitize_filename(f.name)}_no_bg.png"
54
+ outputs.append((name, buf.getvalue()))
55
+
56
+ with st.container(border=True):
57
+ left, right = st.columns(2)
58
+ left.image(original, caption="Original", width="stretch")
59
+ right.image(result, caption="Background removed", width="stretch")
60
+ st.download_button("Download PNG", buf.getvalue(), name, "image/png", key=f"bg_dl_{i}")
61
+ progress.progress(i / len(files), text=f"Processed {i}/{len(files)}")
62
+ progress.empty()
63
+
64
+ if len(outputs) > 1:
65
+ st.download_button("Download all as ZIP", build_zip(outputs), "backgrounds_removed.zip", "application/zip", type="primary")