""" Streamlit Universal File‑Format Changer -------------------------------------- A Streamlit app ready for Hugging Face Spaces that **actually converts file contents when possible**, instead of merely renaming extensions. * Image ↔ image conversions via **Pillow** (JPEG, PNG, GIF, BMP, TIFF, ICO, WEBP) * Plain‑text files kept intact but re‑encoded (UTF‑8) when changing among text‑like extensions (txt, md, csv, json, xml, html, css, js) * Disallowed uploads: `.exe`, `.bin` * Everything is bundled into one ZIP download. Created 2025‑05‑22 • v2 """ from __future__ import annotations # NOTE: Set env vars *before* importing Streamlit ------------------------------ import os, pathlib os.environ.setdefault("STREAMLIT_HOME", "/tmp/.streamlit") os.environ.setdefault("HOME", "/tmp") pathlib.Path(os.environ["STREAMLIT_HOME"]).mkdir(parents=True, exist_ok=True) import io import zipfile from datetime import datetime from pathlib import Path import streamlit as st from PIL import Image # Pillow for real image conversion # ----------------------------------------------------------------------------- # Supported extensions --------------------------------------------------------- # ----------------------------------------------------------------------------- IMAGE_EXTS = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".ico", ".webp", } TEXT_EXTS = { ".txt", ".md", ".csv", ".json", ".xml", ".html", ".css", ".js", } ARCHIVE_EXTS = {".zip", ".tar", ".gz", ".7z"} MEDIA_EXTS = {".mp3", ".wav", ".mp4", ".avi", ".mkv", ".mov"} DOC_EXTS = {".pdf", ".doc", ".docx"} ALLOWED_TARGET_EXTS: list[str] = sorted( IMAGE_EXTS | TEXT_EXTS | ARCHIVE_EXTS | MEDIA_EXTS | DOC_EXTS ) DISALLOWED_SOURCE_EXTS = {".exe", ".bin"} # ----------------------------------------------------------------------------- # Helpers ---------------------------------------------------------------------- # ----------------------------------------------------------------------------- def sidebar_target_extension() -> str: st.sidebar.header("Settings") query = st.sidebar.text_input("Filter extensions… (optional)") filtered = [e for e in ALLOWED_TARGET_EXTS if query.lower() in e] if not filtered: st.sidebar.error("No extension matches that filter.") target_ext = st.sidebar.selectbox( "Choose target extension (applied to **all** files)", filtered or ALLOWED_TARGET_EXTS, index=(filtered or ALLOWED_TARGET_EXTS).index(".png") if ".png" in (filtered or ALLOWED_TARGET_EXTS) else 0, ) st.sidebar.markdown( "*Images are truly converted. Text files are re‑saved as UTF‑8. " "Other combinations fall back to a safe rename.*" ) return target_ext def uploader(): return st.file_uploader( "Upload any files (multiple allowed)", accept_multiple_files=True, type=None, # accept *all* extensions help="Drag‑and‑drop or click to browse.", ) # ----------------------------------------------------------------------------- # Conversion logic ------------------------------------------------------------- # ----------------------------------------------------------------------------- def convert_image(data: bytes, target_ext: str) -> bytes: """Return `bytes` of the image converted to `target_ext`. Raises if Pillow cannot save in that format.""" img = Image.open(io.BytesIO(data)) buf = io.BytesIO() # Map certain extensions to Pillow format names pil_fmt = { ".jpg": "JPEG", ".jpeg": "JPEG", ".png": "PNG", ".gif": "GIF", ".bmp": "BMP", ".tiff": "TIFF", ".ico": "ICO", ".webp": "WEBP", }[target_ext] img.save(buf, format=pil_fmt) buf.seek(0) return buf.read() def convert_text(data: bytes, _target_ext: str) -> bytes: """Return data re‑encoded as UTF‑8 (no format change).""" text = data.decode("utf‑8", errors="ignore") return text.encode("utf‑8") def convert_file(file: st.runtime.uploaded_file_manager.UploadedFile, target_ext: str) -> tuple[bytes, str]: """Try to convert and return (bytes, conversion_note). On failure, return original data with a note that only rename happened.""" orig_ext = Path(file.name).suffix.lower() raw = file.read() try: if orig_ext in IMAGE_EXTS and target_ext in IMAGE_EXTS: return convert_image(raw, target_ext), "image converted" if orig_ext in TEXT_EXTS and target_ext in TEXT_EXTS: return convert_text(raw, target_ext), "text re‑encoded" except Exception as err: st.warning(f"⚠️ Could not convert **{file.name}**: {err}. Falling back to rename.") # Fallback: no conversion return raw, "renamed only" # ----------------------------------------------------------------------------- # Zip packaging --------------------------------------------------------------- # ----------------------------------------------------------------------------- def package_zip(files: list[st.runtime.uploaded_file_manager.UploadedFile], target_ext: str) -> io.BytesIO: buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for file in files: orig_path = Path(file.name) if orig_path.suffix.lower() in DISALLOWED_SOURCE_EXTS: st.warning(f"⏭️ Skipping disallowed file: **{orig_path.name}**") continue data, note = convert_file(file, target_ext) new_name = orig_path.with_suffix(target_ext).name zf.writestr(new_name, data) st.success(f"✅ {note} • **{orig_path.name}** → **{new_name}**") buf.seek(0) return buf # ----------------------------------------------------------------------------- # Main ------------------------------------------------------------------------ # ----------------------------------------------------------------------------- def main(): st.set_page_config("Universal Format Changer", page_icon="🔄", layout="centered") st.title("🔄 Universal File‑Format Changer") st.write("Upload files, pick a target extension, and download a ZIP with the converted files.") target_ext = sidebar_target_extension() files = uploader() if files and st.button("🚀 Convert & Download"): zip_buf = package_zip(files, target_ext) ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") st.download_button( "⬇️ Get ZIP", zip_buf, file_name=f"converted_{ts}.zip", mime="application/zip" ) st.caption("© 2025 Universal Changer • Streamlit • Hugging Face Spaces") if __name__ == "__main__": main()