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,90 @@
1
+ """Compare a page's visible text with the snapshot taken on the previous check.
2
+
3
+ Snapshots live in the system temp directory, so on hosted platforms they last
4
+ only as long as the server instance. Email summaries are sent when SMTP
5
+ credentials are configured in Streamlit secrets (see README).
6
+ """
7
+ import difflib
8
+ import hashlib
9
+ import smtplib
10
+ import tempfile
11
+ from datetime import datetime
12
+ from email.mime.text import MIMEText
13
+ from pathlib import Path
14
+
15
+ import streamlit as st
16
+ from bs4 import BeautifulSoup
17
+
18
+ from core.net import fetch, normalize_url
19
+
20
+ SNAPSHOT_DIR = Path(tempfile.gettempdir()) / "mini_toolkit_snapshots"
21
+ SMTP_KEYS = ("smtp_server", "smtp_port", "smtp_user", "smtp_pass")
22
+
23
+
24
+ def page_text(url: str) -> str:
25
+ soup = BeautifulSoup(fetch(url, timeout=15).text, "html.parser")
26
+ for tag in soup(["script", "style", "noscript"]):
27
+ tag.decompose()
28
+ return "\n".join(line.strip() for line in soup.get_text("\n").splitlines() if line.strip())
29
+
30
+
31
+ def snapshot_path(url: str) -> Path:
32
+ return SNAPSHOT_DIR / f"{hashlib.sha256(url.encode()).hexdigest()[:24]}.txt"
33
+
34
+
35
+ def smtp_configured() -> bool:
36
+ try:
37
+ return all(key in st.secrets for key in SMTP_KEYS)
38
+ except Exception: # no secrets file at all
39
+ return False
40
+
41
+
42
+ def send_email(to: str, subject: str, body: str) -> None:
43
+ msg = MIMEText(body)
44
+ msg["Subject"], msg["From"], msg["To"] = subject, st.secrets["smtp_user"], to
45
+ with smtplib.SMTP(st.secrets["smtp_server"], int(st.secrets["smtp_port"]), timeout=15) as server:
46
+ server.starttls()
47
+ server.login(st.secrets["smtp_user"], st.secrets["smtp_pass"])
48
+ server.send_message(msg)
49
+
50
+
51
+ def render() -> None:
52
+ url = normalize_url(st.text_input("Page URL", placeholder="https://example.com/pricing"))
53
+ email = st.text_input("Email a summary to (optional)", disabled=not smtp_configured(),
54
+ help=None if smtp_configured() else "Email is disabled: no SMTP credentials are configured on this server.")
55
+ if not st.button("Check for changes", type="primary", disabled=not url):
56
+ st.caption("The first check saves a snapshot; later checks show what changed since then.")
57
+ return
58
+
59
+ with st.spinner("Fetching page…"):
60
+ try:
61
+ current = page_text(url)
62
+ except Exception as exc:
63
+ st.error(f"Could not fetch page: {exc}")
64
+ return
65
+
66
+ path = snapshot_path(url)
67
+ previous = path.read_text(encoding="utf-8") if path.exists() else None
68
+ SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
69
+ path.write_text(current, encoding="utf-8")
70
+
71
+ if previous is None:
72
+ st.info("Snapshot saved. Run the check again later to see changes.")
73
+ return
74
+ diff = list(difflib.unified_diff(previous.splitlines(), current.splitlines(), "before", "after", lineterm="", n=1))
75
+ if not diff:
76
+ st.success("No changes since the last check.")
77
+ return
78
+
79
+ added = sum(1 for line in diff if line.startswith("+") and not line.startswith("+++"))
80
+ removed = sum(1 for line in diff if line.startswith("-") and not line.startswith("---"))
81
+ st.warning(f"Page changed: {added} line(s) added, {removed} removed.")
82
+ summary = "\n".join(diff[:200])
83
+ st.code(summary, language="diff")
84
+
85
+ if email:
86
+ try:
87
+ send_email(email, f"Change detected: {url}", f"Checked {datetime.now():%Y-%m-%d %H:%M}\n\n{summary}")
88
+ st.success(f"Summary emailed to {email}.")
89
+ except Exception as exc:
90
+ st.error(f"Could not send email: {exc}")
@@ -0,0 +1,42 @@
1
+ from urllib.parse import urlparse
2
+
3
+ import requests
4
+ import streamlit as st
5
+
6
+ # Webhooks may only point at these services, so the tool can't be used to POST to arbitrary hosts.
7
+ PLATFORMS = {
8
+ "Slack": (("hooks.slack.com",), lambda msg: {"text": msg}),
9
+ "Microsoft Teams": ((".webhook.office.com", ".logic.azure.com", "outlook.office.com"), lambda msg: {"text": msg}),
10
+ "Discord": (("discord.com", "discordapp.com"), lambda msg: {"content": msg}),
11
+ }
12
+
13
+
14
+ def allowed(url: str, hosts) -> bool:
15
+ parsed = urlparse(url)
16
+ host = parsed.hostname or ""
17
+ return parsed.scheme == "https" and any(host == h or host.endswith(h if h.startswith(".") else "." + h) for h in hosts)
18
+
19
+
20
+ def send(platform: str, webhook: str, message: str) -> requests.Response:
21
+ _, payload = PLATFORMS[platform]
22
+ return requests.post(webhook, json=payload(message), timeout=10)
23
+
24
+
25
+ def render() -> None:
26
+ platform = st.radio("Platform", list(PLATFORMS), horizontal=True)
27
+ webhook = st.text_input("Incoming webhook URL", type="password", help="Treated like a secret — it is never stored.").strip()
28
+ message = st.text_area("Message", placeholder="Deploy finished ✅")
29
+
30
+ if st.button("Send", type="primary", disabled=not (webhook and message.strip())):
31
+ if not allowed(webhook, PLATFORMS[platform][0]):
32
+ st.error(f"That doesn't look like a {platform} webhook URL.")
33
+ return
34
+ try:
35
+ response = send(platform, webhook, message)
36
+ except requests.RequestException as exc:
37
+ st.error(f"Request failed: {exc}")
38
+ return
39
+ if response.ok:
40
+ st.success(f"Message sent to {platform}.")
41
+ else:
42
+ st.error(f"{platform} rejected the message ({response.status_code}): {response.text[:200]}")
@@ -0,0 +1,40 @@
1
+ import streamlit as st
2
+ from PIL import Image
3
+
4
+ from core.ui import missing_dependency
5
+
6
+ DISPLAY_WIDTH = 700
7
+
8
+
9
+ def render() -> None:
10
+ try:
11
+ from streamlit_image_coordinates import streamlit_image_coordinates
12
+ except ImportError:
13
+ missing_dependency("streamlit-image-coordinates")
14
+ return
15
+
16
+ uploaded = st.file_uploader("Upload an image", type=["png", "jpg", "jpeg", "bmp", "webp"], key="color_picker_upload")
17
+ if not uploaded:
18
+ st.info("Upload an image, then click anywhere on it to sample a colour.")
19
+ return
20
+
21
+ image = Image.open(uploaded).convert("RGB")
22
+ width = min(image.width, DISPLAY_WIDTH)
23
+ st.caption("Click on the image to pick a colour.")
24
+ coords = streamlit_image_coordinates(image, width=width, key="color_picker_coords")
25
+ if not coords:
26
+ return
27
+
28
+ # The image may be displayed scaled down, so map the click back to source pixels.
29
+ scale = image.width / width
30
+ x = min(int(coords["x"] * scale), image.width - 1)
31
+ y = min(int(coords["y"] * scale), image.height - 1)
32
+ r, g, b = image.getpixel((x, y))
33
+ hex_code = f"#{r:02X}{g:02X}{b:02X}"
34
+
35
+ swatch, values = st.columns([1, 3])
36
+ swatch.markdown(f"<div class='color-swatch' style='background:{hex_code}'></div>", unsafe_allow_html=True)
37
+ with values:
38
+ st.caption(f"Pixel ({x}, {y})")
39
+ st.code(hex_code, language=None)
40
+ st.code(f"rgb({r}, {g}, {b})", language=None)
@@ -0,0 +1,121 @@
1
+ import io
2
+ import shutil
3
+ import subprocess
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import streamlit as st
8
+
9
+ from core.pdf import pdf_safe
10
+ from core.ui import missing_dependency, sanitize_filename
11
+
12
+ PDF_MIME = "application/pdf"
13
+ DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
14
+
15
+ # Conversion label -> (input extension, output extension, output MIME)
16
+ CONVERSIONS = {
17
+ "DOCX → PDF": ("docx", "pdf", PDF_MIME),
18
+ "PDF → DOCX": ("pdf", "docx", DOCX_MIME),
19
+ "PDF → TXT": ("pdf", "txt", "text/plain"),
20
+ "DOCX → TXT": ("docx", "txt", "text/plain"),
21
+ "TXT → PDF": ("txt", "pdf", PDF_MIME),
22
+ }
23
+
24
+
25
+ def docx_to_pdf(data: bytes, filename: str) -> bytes:
26
+ with tempfile.TemporaryDirectory() as tmp:
27
+ src = Path(tmp) / filename
28
+ src.write_bytes(data)
29
+ office = shutil.which("libreoffice") or shutil.which("soffice")
30
+ if office:
31
+ subprocess.run([office, "--headless", "--convert-to", "pdf", "--outdir", tmp, str(src)],
32
+ check=True, timeout=120, capture_output=True)
33
+ return src.with_suffix(".pdf").read_bytes()
34
+ # Fallback for Windows/macOS machines with Microsoft Word installed.
35
+ from docx2pdf import convert
36
+
37
+ try:
38
+ import pythoncom
39
+ pythoncom.CoInitialize()
40
+ except ImportError:
41
+ pass
42
+ out = Path(tmp) / "output.pdf"
43
+ convert(str(src), str(out))
44
+ return out.read_bytes()
45
+
46
+
47
+ def pdf_to_docx(data: bytes) -> bytes:
48
+ from pdf2docx import Converter
49
+
50
+ with tempfile.TemporaryDirectory() as tmp:
51
+ src, out = Path(tmp) / "input.pdf", Path(tmp) / "output.docx"
52
+ src.write_bytes(data)
53
+ converter = Converter(str(src))
54
+ try:
55
+ converter.convert(str(out))
56
+ finally:
57
+ converter.close()
58
+ return out.read_bytes()
59
+
60
+
61
+ def pdf_to_txt(data: bytes) -> bytes:
62
+ from pypdf import PdfReader
63
+
64
+ reader = PdfReader(io.BytesIO(data))
65
+ return "\n\n".join(page.extract_text() or "" for page in reader.pages).encode("utf-8")
66
+
67
+
68
+ def docx_to_txt(data: bytes) -> bytes:
69
+ from docx import Document
70
+
71
+ return "\n".join(p.text for p in Document(io.BytesIO(data)).paragraphs).encode("utf-8")
72
+
73
+
74
+ def txt_to_pdf(data: bytes) -> bytes:
75
+ from fpdf import FPDF
76
+
77
+ text = data.decode("utf-8", errors="replace")
78
+ pdf = FPDF()
79
+ pdf.set_auto_page_break(auto=True, margin=15)
80
+ pdf.add_page()
81
+ pdf.set_font("Helvetica", size=11)
82
+ pdf.multi_cell(0, 6, pdf_safe(text))
83
+ return bytes(pdf.output())
84
+
85
+
86
+ def convert(label: str, data: bytes, filename: str) -> bytes:
87
+ if label == "DOCX → PDF":
88
+ return docx_to_pdf(data, filename)
89
+ if label == "PDF → DOCX":
90
+ return pdf_to_docx(data)
91
+ if label == "PDF → TXT":
92
+ return pdf_to_txt(data)
93
+ if label == "DOCX → TXT":
94
+ return docx_to_txt(data)
95
+ return txt_to_pdf(data)
96
+
97
+
98
+ def render() -> None:
99
+ label = st.radio("Conversion", list(CONVERSIONS), horizontal=True)
100
+ src_ext, out_ext, mime = CONVERSIONS[label]
101
+ uploaded = st.file_uploader(f"Upload a .{src_ext} file", type=[src_ext], key=f"docconv_{src_ext}")
102
+ if not uploaded:
103
+ st.info("Upload a file to convert.")
104
+ return
105
+
106
+ with st.spinner("Converting…"):
107
+ try:
108
+ result = convert(label, uploaded.getvalue(), uploaded.name)
109
+ except ImportError as exc:
110
+ missing_dependency(exc.name or str(exc))
111
+ return
112
+ except Exception as exc:
113
+ st.error(f"Conversion failed: {exc}")
114
+ if label == "DOCX → PDF":
115
+ st.caption("DOCX → PDF needs LibreOffice on the server, or Microsoft Word locally.")
116
+ return
117
+
118
+ st.success("Conversion complete.")
119
+ if out_ext == "txt":
120
+ st.text_area("Preview", result.decode("utf-8", errors="replace")[:5000], height=240)
121
+ st.download_button(f"Download .{out_ext}", result, f"{sanitize_filename(uploaded.name)}.{out_ext}", mime, type="primary")
@@ -0,0 +1,78 @@
1
+ import io
2
+ from pathlib import Path
3
+
4
+ import streamlit as st
5
+ from PIL import Image
6
+
7
+ from core.ui import build_zip, image_uploader
8
+
9
+ QUALITY = {"Smallest file": 35, "Balanced": 65, "Best quality": 85}
10
+
11
+
12
+ def shrink(f, quality: int, max_side: int) -> tuple:
13
+ """Re-encode an image smaller. Returns (filename, bytes)."""
14
+ image = Image.open(f)
15
+ if max_side and max(image.size) > max_side:
16
+ image.thumbnail((max_side, max_side))
17
+ buf = io.BytesIO()
18
+ suffix = Path(f.name).suffix.lower()
19
+ if suffix == ".png" and image.mode in ("RGBA", "LA", "P"):
20
+ image.save(buf, format="PNG", optimize=True)
21
+ name = f.name
22
+ elif suffix == ".webp":
23
+ image.save(buf, format="WEBP", quality=quality)
24
+ name = f.name
25
+ else:
26
+ image.convert("RGB").save(buf, format="JPEG", quality=quality, optimize=True)
27
+ name = f"{Path(f.name).stem}.jpg"
28
+ return name, buf.getvalue()
29
+
30
+
31
+ def _render_zip() -> None:
32
+ files = st.file_uploader("Upload any files", accept_multiple_files=True, key="zip_upload")
33
+ if not files:
34
+ st.info("Upload files to bundle into a ZIP archive.")
35
+ return
36
+ data = build_zip((f.name, f.getvalue()) for f in files)
37
+ original = sum(f.size for f in files)
38
+ st.metric("Archive size", f"{len(data) / 1024:.1f} KB", f"{(len(data) - original) / max(original, 1):+.0%}", delta_color="inverse")
39
+ st.download_button("Download ZIP", data, "archive.zip", "application/zip", type="primary")
40
+
41
+
42
+ def _render_images() -> None:
43
+ left, right = st.columns(2)
44
+ quality = QUALITY[left.radio("Quality", list(QUALITY), index=1, horizontal=True)]
45
+ max_side = right.selectbox("Max dimension", [0, 3840, 2560, 1920, 1280, 800], format_func=lambda v: "Keep original" if v == 0 else f"{v}px")
46
+ files = image_uploader("shrink_upload", types=("png", "jpg", "jpeg", "webp"))
47
+ if not files:
48
+ st.info("Upload images to reduce their file size.")
49
+ return
50
+
51
+ outputs, before, after = [], 0, 0
52
+ for f in files:
53
+ try:
54
+ name, data = shrink(f, quality, max_side)
55
+ except Exception as exc:
56
+ st.warning(f"Skipped {f.name}: {exc}")
57
+ continue
58
+ outputs.append((name, data))
59
+ before += f.size
60
+ after += len(data)
61
+
62
+ if outputs:
63
+ cols = st.columns(3)
64
+ cols[0].metric("Before", f"{before / 1024:.0f} KB")
65
+ cols[1].metric("After", f"{after / 1024:.0f} KB")
66
+ cols[2].metric("Saved", f"{1 - after / max(before, 1):.0%}")
67
+ if len(outputs) == 1:
68
+ st.download_button("Download image", outputs[0][1], outputs[0][0], type="primary")
69
+ else:
70
+ st.download_button("Download all (ZIP)", build_zip(outputs), "compressed_images.zip", "application/zip", type="primary")
71
+
72
+
73
+ def render() -> None:
74
+ images_tab, zip_tab = st.tabs(["Shrink images", "Create ZIP"])
75
+ with images_tab:
76
+ _render_images()
77
+ with zip_tab:
78
+ _render_zip()
@@ -0,0 +1,53 @@
1
+ from datetime import date, timedelta
2
+
3
+ import requests
4
+ import streamlit as st
5
+
6
+ PERIODS = {"Today": 1, "This week": 7, "This month": 30}
7
+
8
+
9
+ @st.cache_data(ttl=900, show_spinner=False)
10
+ def trending(days: int, language: str, limit: int = 20) -> list:
11
+ """Approximates GitHub Trending: most-starred repositories created in the window."""
12
+ query = f"created:>{date.today() - timedelta(days=days)}"
13
+ if language:
14
+ query += f" language:{language}"
15
+ response = requests.get(
16
+ "https://api.github.com/search/repositories",
17
+ params={"q": query, "sort": "stars", "order": "desc", "per_page": limit},
18
+ headers={"Accept": "application/vnd.github+json"},
19
+ timeout=15,
20
+ )
21
+ response.raise_for_status()
22
+ return response.json().get("items", [])
23
+
24
+
25
+ def render() -> None:
26
+ left, right = st.columns(2)
27
+ period = left.selectbox("Created", list(PERIODS), index=1)
28
+ language = right.text_input("Language", placeholder="Any — e.g. python, rust").strip().lower()
29
+
30
+ with st.spinner("Fetching from GitHub…"):
31
+ try:
32
+ repos = trending(PERIODS[period], language)
33
+ except requests.HTTPError as exc:
34
+ if exc.response is not None and exc.response.status_code == 403:
35
+ st.error("GitHub's rate limit was reached. Try again in a minute.")
36
+ else:
37
+ st.error(f"GitHub request failed: {exc}")
38
+ return
39
+ except requests.RequestException as exc:
40
+ st.error(f"Could not reach GitHub: {exc}")
41
+ return
42
+
43
+ if not repos:
44
+ st.info("No repositories found. Try a longer period or another language.")
45
+ return
46
+ for repo in repos:
47
+ with st.container(border=True):
48
+ title, stars = st.columns([5, 1])
49
+ title.markdown(f"**[{repo['full_name']}]({repo['html_url']})**")
50
+ stars.markdown(f"⭐ {repo['stargazers_count']:,}")
51
+ if repo.get("description"):
52
+ st.write(repo["description"])
53
+ st.caption(" · ".join(filter(None, [repo.get("language"), f"{repo['forks_count']:,} forks"])))
@@ -0,0 +1,43 @@
1
+ import hashlib
2
+
3
+ import streamlit as st
4
+
5
+ ALGORITHMS = {
6
+ "MD5": hashlib.md5,
7
+ "SHA-1": hashlib.sha1,
8
+ "SHA-256": hashlib.sha256,
9
+ "SHA-512": hashlib.sha512,
10
+ "SHA3-256": hashlib.sha3_256,
11
+ "BLAKE2b": hashlib.blake2b,
12
+ }
13
+
14
+
15
+ def digest(data: bytes, algorithm: str) -> str:
16
+ return ALGORITHMS[algorithm](data).hexdigest()
17
+
18
+
19
+ def render() -> None:
20
+ source = st.radio("Input", ["Text", "File"], horizontal=True)
21
+ if source == "Text":
22
+ text = st.text_area("Text to hash", height=120)
23
+ data = text.encode("utf-8") if text else None
24
+ else:
25
+ uploaded = st.file_uploader("File to hash", key="hash_file")
26
+ data = uploaded.getvalue() if uploaded else None
27
+
28
+ selected = st.multiselect("Algorithms", list(ALGORITHMS), default=["MD5", "SHA-256"])
29
+ if data is None:
30
+ st.info("Enter text or upload a file to compute its hash.")
31
+ return
32
+
33
+ for algorithm in selected:
34
+ st.caption(algorithm)
35
+ st.code(digest(data, algorithm), language=None)
36
+
37
+ expected = st.text_input("Verify against a known hash (optional)").strip().lower()
38
+ if expected:
39
+ match = next((a for a in selected if digest(data, a) == expected), None)
40
+ if match:
41
+ st.success(f"Match — the {match} hash is identical.")
42
+ else:
43
+ st.error("No match among the selected algorithms.")
@@ -0,0 +1,63 @@
1
+ import io
2
+
3
+ import streamlit as st
4
+ from PIL import Image
5
+
6
+ from core.ui import build_zip, image_uploader, sanitize_filename, section
7
+
8
+ # Display label -> (Pillow format, extension, MIME type, supports alpha)
9
+ FORMATS = {
10
+ "PNG": ("PNG", "png", "image/png", True),
11
+ "JPG": ("JPEG", "jpg", "image/jpeg", False),
12
+ "WEBP": ("WEBP", "webp", "image/webp", True),
13
+ "BMP": ("BMP", "bmp", "image/bmp", False),
14
+ "TIFF": ("TIFF", "tiff", "image/tiff", True),
15
+ }
16
+
17
+
18
+ def convert(image: Image.Image, label: str) -> bytes:
19
+ fmt, _, _, supports_alpha = FORMATS[label]
20
+ if not supports_alpha and image.mode in ("RGBA", "LA", "P"):
21
+ # Flatten transparency onto white instead of letting it turn black.
22
+ rgba = image.convert("RGBA")
23
+ background = Image.new("RGB", rgba.size, (255, 255, 255))
24
+ background.paste(rgba, mask=rgba.split()[-1])
25
+ image = background
26
+ elif not supports_alpha and image.mode != "RGB":
27
+ image = image.convert("RGB")
28
+ buf = io.BytesIO()
29
+ image.save(buf, format=fmt)
30
+ return buf.getvalue()
31
+
32
+
33
+ def render() -> None:
34
+ target = st.radio("Convert to", list(FORMATS), horizontal=True)
35
+ files = image_uploader("converter_upload")
36
+ if not files:
37
+ st.info("Upload one or more images to convert.")
38
+ return
39
+
40
+ _, ext, mime, _ = FORMATS[target]
41
+ section("Converted images")
42
+ outputs = []
43
+ for i, f in enumerate(files):
44
+ try:
45
+ original = Image.open(f)
46
+ data = convert(original, target)
47
+ except Exception as exc:
48
+ st.error(f"Could not convert {f.name}: {exc}")
49
+ continue
50
+ name = f"{sanitize_filename(f.name)}.{ext}"
51
+ outputs.append((name, data))
52
+ with st.container(border=True):
53
+ cols = st.columns([1, 2])
54
+ cols[0].image(original, width="stretch")
55
+ cols[1].markdown(f"**{f.name}** → `{name}`")
56
+ cols[1].caption(
57
+ f"{original.format} · {original.width}×{original.height} · "
58
+ f"{f.size / 1024:.1f} KB → {len(data) / 1024:.1f} KB"
59
+ )
60
+ cols[1].download_button(f"Download {target}", data, name, mime, key=f"conv_dl_{i}")
61
+
62
+ if len(outputs) > 1:
63
+ st.download_button("Download all as ZIP", build_zip(outputs), "converted_images.zip", "application/zip", type="primary")
@@ -0,0 +1,67 @@
1
+ from concurrent.futures import ThreadPoolExecutor
2
+ from pathlib import PurePosixPath
3
+ from urllib.parse import urljoin, urlparse
4
+
5
+ import streamlit as st
6
+ from bs4 import BeautifulSoup
7
+
8
+ from core.net import fetch
9
+ from core.ui import build_zip
10
+
11
+ MAX_IMAGES = 60
12
+
13
+
14
+ def find_images(url: str) -> list:
15
+ response = fetch(url, timeout=15)
16
+ soup = BeautifulSoup(response.text, "html.parser")
17
+ found = []
18
+ for img in soup.find_all("img"):
19
+ src = img.get("src") or img.get("data-src")
20
+ if src and not src.startswith("data:"):
21
+ found.append(urljoin(response.url, src))
22
+ return list(dict.fromkeys(found))
23
+
24
+
25
+ def download(url: str):
26
+ try:
27
+ response = fetch(url, timeout=15)
28
+ if response.ok and response.headers.get("content-type", "").startswith("image/"):
29
+ return url, response.content
30
+ except Exception:
31
+ pass
32
+ return url, None
33
+
34
+
35
+ def render() -> None:
36
+ url = st.text_input("Page URL", placeholder="https://example.com")
37
+ if st.button("Find images", type="primary", disabled=not url.strip()):
38
+ with st.spinner("Scanning page…"):
39
+ try:
40
+ st.session_state.img_dl_urls = find_images(url)[:MAX_IMAGES]
41
+ except Exception as exc:
42
+ st.error(f"Could not load page: {exc}")
43
+ return
44
+
45
+ urls = st.session_state.get("img_dl_urls")
46
+ if urls is None:
47
+ return
48
+ if not urls:
49
+ st.warning("No images found on that page.")
50
+ return
51
+
52
+ st.caption(f"Found {len(urls)} image{'s' if len(urls) != 1 else ''} (showing up to {MAX_IMAGES}).")
53
+ cols = st.columns(4)
54
+ for i, img_url in enumerate(urls):
55
+ cols[i % 4].image(img_url, width="stretch")
56
+
57
+ if st.button("Prepare ZIP download"):
58
+ with st.spinner("Downloading images…"), ThreadPoolExecutor(max_workers=8) as pool:
59
+ results = [(u, data) for u, data in pool.map(download, urls) if data]
60
+ entries, seen = [], set()
61
+ for i, (img_url, data) in enumerate(results):
62
+ name = PurePosixPath(urlparse(img_url).path).name or f"image_{i}"
63
+ if name in seen:
64
+ name = f"{i}_{name}"
65
+ seen.add(name)
66
+ entries.append((name, data))
67
+ st.download_button(f"Download {len(entries)} images (ZIP)", build_zip(entries), "images.zip", "application/zip", type="primary")
@@ -0,0 +1,54 @@
1
+ import io
2
+
3
+ import streamlit as st
4
+ from PIL import Image, ImageEnhance
5
+
6
+ from core.ui import build_zip, image_uploader, sanitize_filename, section
7
+
8
+
9
+ def enhance(image: Image.Image, brightness: float, contrast: float, color: float, sharpness: float) -> Image.Image:
10
+ if image.mode not in ("RGB", "RGBA"):
11
+ image = image.convert("RGBA")
12
+ for enhancer, factor in (
13
+ (ImageEnhance.Brightness, brightness),
14
+ (ImageEnhance.Contrast, contrast),
15
+ (ImageEnhance.Color, color),
16
+ (ImageEnhance.Sharpness, sharpness),
17
+ ):
18
+ image = enhancer(image).enhance(factor)
19
+ return image
20
+
21
+
22
+ def render() -> None:
23
+ cols = st.columns(4)
24
+ brightness = cols[0].slider("Brightness", 0.5, 2.0, 1.0, 0.05)
25
+ contrast = cols[1].slider("Contrast", 0.5, 2.0, 1.2, 0.05)
26
+ color = cols[2].slider("Colour", 0.5, 2.0, 1.0, 0.05)
27
+ sharpness = cols[3].slider("Sharpness", 0.5, 2.0, 1.2, 0.05)
28
+
29
+ files = image_uploader("enhancer_upload")
30
+ if not files:
31
+ st.info("Upload one or more images to enhance.")
32
+ return
33
+
34
+ section("Enhanced images")
35
+ outputs = []
36
+ for i, f in enumerate(files):
37
+ try:
38
+ original = Image.open(f)
39
+ result = enhance(original, brightness, contrast, color, sharpness)
40
+ except Exception as exc:
41
+ st.error(f"Could not enhance {f.name}: {exc}")
42
+ continue
43
+ buf = io.BytesIO()
44
+ result.save(buf, format="PNG")
45
+ name = f"{sanitize_filename(f.name)}_enhanced.png"
46
+ outputs.append((name, buf.getvalue()))
47
+ with st.container(border=True):
48
+ left, right = st.columns(2)
49
+ left.image(original, caption="Original", width="stretch")
50
+ right.image(result, caption="Enhanced", width="stretch")
51
+ st.download_button("Download PNG", buf.getvalue(), name, "image/png", key=f"enh_dl_{i}")
52
+
53
+ if len(outputs) > 1:
54
+ st.download_button("Download all as ZIP", build_zip(outputs), "enhanced_images.zip", "application/zip", type="primary")