import io import math import os import re from fastapi import FastAPI, File, Form, UploadFile from fastapi.responses import HTMLResponse, PlainTextResponse, Response from PIL import Image app = FastAPI() ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} RESAMPLE_LANCZOS = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.BICUBIC) def natural_name_key(full_name: str) -> tuple[list[object], str]: normalized = (full_name or "").replace("\\", "/") base = os.path.basename(normalized) parts = re.split(r"(\d+)", base.lower()) key: list[object] = [int(p) if p.isdigit() else p for p in parts] return key, normalized.lower() HTML = """ Folder Sprite Builder

🗂️ Folder Sprite Builder

選擇資料夾後,依 Columns 與總寬度自動縮放並合併為 Sprite 圖

支援 png/jpg/jpeg/webp/bmp/gif

目前資料夾中的圖片

""" @app.get("/", response_class=HTMLResponse) async def index(): return HTML @app.post("/build") async def build_folder_sprite( files: list[UploadFile] = File(...), cols: int = Form(...), total_width: int = Form(...), output_name: str = Form("folder_sprite.png"), ): if cols <= 0: return PlainTextResponse("Columns 必須大於 0", status_code=400) if total_width <= 0: return PlainTextResponse("總寬度必須大於 0", status_code=400) tile_w = total_width // cols if tile_w <= 0: return PlainTextResponse("總寬度過小,請提高總寬度或降低 Columns", status_code=400) image_files: list[UploadFile] = [] for f in files: _, ext = os.path.splitext((f.filename or "").lower()) if ext in ALLOWED_EXTENSIONS: image_files.append(f) if not image_files: return PlainTextResponse("找不到可用圖片檔", status_code=400) image_files.sort(key=lambda f: natural_name_key(f.filename or "")) resized_images: list[Image.Image] = [] resized_heights: list[int] = [] for f in image_files: try: data = await f.read() img = Image.open(io.BytesIO(data)).convert("RGBA") except Exception: continue if img.width <= 0 or img.height <= 0: continue ratio = min(1.0, tile_w / float(img.width)) new_w = max(1, int(round(img.width * ratio))) new_h = max(1, int(round(img.height * ratio))) resized = img.resize((new_w, new_h), RESAMPLE_LANCZOS) resized_images.append(resized) resized_heights.append(new_h) if not resized_images: return PlainTextResponse("圖片讀取失敗,請確認檔案格式", status_code=400) cell_h = max(resized_heights) rows = math.ceil(len(resized_images) / cols) out_h = rows * cell_h sheet = Image.new("RGBA", (total_width, out_h), (0, 0, 0, 0)) for idx, img in enumerate(resized_images): row = idx // cols col = idx % cols x = col * tile_w + (tile_w - img.width) // 2 y = row * cell_h + (cell_h - img.height) // 2 sheet.paste(img, (x, y), img) final_name = output_name.strip() or "folder_sprite.png" if not final_name.lower().endswith(".png"): final_name += ".png" buf = io.BytesIO() sheet.save(buf, format="PNG") buf.seek(0) return Response( content=buf.read(), media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{final_name}"'}, ) if __name__ == "__main__": import uvicorn uvicorn.run("folder_sprite_builder:app", host="0.0.0.0", port=8005, reload=True)