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.
- mini_toolkit/__init__.py +3 -0
- mini_toolkit/__main__.py +3 -0
- mini_toolkit/app.py +111 -0
- mini_toolkit/cli.py +40 -0
- mini_toolkit/core/__init__.py +1 -0
- mini_toolkit/core/net.py +56 -0
- mini_toolkit/core/pdf.py +12 -0
- mini_toolkit/core/registry.py +75 -0
- mini_toolkit/core/ui.py +90 -0
- mini_toolkit/streamlit_config.toml +16 -0
- mini_toolkit/style/style.css +171 -0
- mini_toolkit/tools/__init__.py +1 -0
- mini_toolkit/tools/background_remover.py +65 -0
- mini_toolkit/tools/change_tracker.py +90 -0
- mini_toolkit/tools/chat_alert.py +42 -0
- mini_toolkit/tools/color_picker.py +40 -0
- mini_toolkit/tools/document_converter.py +121 -0
- mini_toolkit/tools/file_compressor.py +78 -0
- mini_toolkit/tools/github_trending.py +53 -0
- mini_toolkit/tools/hash_generator.py +43 -0
- mini_toolkit/tools/image_converter.py +63 -0
- mini_toolkit/tools/image_downloader.py +67 -0
- mini_toolkit/tools/image_enhancer.py +54 -0
- mini_toolkit/tools/image_watermarker.py +63 -0
- mini_toolkit/tools/ocr.py +93 -0
- mini_toolkit/tools/page_speed.py +45 -0
- mini_toolkit/tools/password_checker.py +53 -0
- mini_toolkit/tools/pdf_tools.py +83 -0
- mini_toolkit/tools/qr_code.py +83 -0
- mini_toolkit/tools/resume_builder.py +385 -0
- mini_toolkit/tools/seo_health.py +64 -0
- mini_toolkit/tools/signature.py +72 -0
- mini_toolkit/tools/ssl_checker.py +68 -0
- mini_toolkit/tools/text_to_speech.py +34 -0
- mini_toolkit/tools/text_utilities.py +36 -0
- mini_toolkit/tools/url_shortener.py +29 -0
- mini_toolkit/tools/website_status.py +39 -0
- streamlit_mini_toolkit-0.1.0.dist-info/METADATA +151 -0
- streamlit_mini_toolkit-0.1.0.dist-info/RECORD +42 -0
- streamlit_mini_toolkit-0.1.0.dist-info/WHEEL +4 -0
- streamlit_mini_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- streamlit_mini_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
mini_toolkit/__init__.py
ADDED
mini_toolkit/__main__.py
ADDED
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."""
|
mini_toolkit/core/net.py
ADDED
|
@@ -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.")
|
mini_toolkit/core/pdf.py
ADDED
|
@@ -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]
|
mini_toolkit/core/ui.py
ADDED
|
@@ -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")
|