diff --git a/.gitignore b/.gitignore index 64ef133..d4aa47c 100644 --- a/.gitignore +++ b/.gitignore @@ -182,9 +182,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ @@ -207,4 +207,4 @@ marimo/_lsp/ __marimo__/ -out/* \ No newline at end of file +out/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7dcd11..b29af18 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,62 @@ repos: + # General file checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-json + - id: check-merge-conflict + - id: check-added-large-files + - id: check-case-conflict + - id: check-docstring-first + - id: debug-statements + - id: mixed-line-ending + args: ['--fix=lf'] + + # Python linting and formatting with Ruff - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.4 + rev: v0.12.8 hooks: - id: ruff - args: ["--fix"] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + args: ["--fix", "--exit-non-zero-on-fix"] + - id: ruff-format + + # Type checking with MyPy + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.17.1 hooks: - - id: end-of-file-fixer - - id: trailing-whitespace \ No newline at end of file + - id: mypy + additional_dependencies: [ + "types-requests", + "types-Pillow", + ] + args: ["--install-types", "--non-interactive"] + + # Security scanning with Bandit + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + args: ["-c", "pyproject.toml"] + additional_dependencies: ["bandit[toml]"] + + # Dockerfile linting + - repo: https://github.com/hadolint/hadolint + rev: v2.13.1-beta + hooks: + - id: hadolint-docker + files: Dockerfile.* + + # Commit message linting + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.8.3 + hooks: + - id: commitizen + stages: [commit-msg] + +# Global configuration +default_stages: [pre-commit] +fail_fast: false diff --git a/README.md b/README.md index 2cedd54..07df1da 100644 --- a/README.md +++ b/README.md @@ -67,4 +67,4 @@ __pycache__ .venv out node_modules -*.png \ No newline at end of file +*.png diff --git a/pyproject.toml b/pyproject.toml index bc56ebb..1bbda19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,17 +8,145 @@ version = "0.2.0" description = "Generate stylish banners from structured inputs (speaker, talk, date, venue, photo)." readme = "README.md" authors = [{ name = "PythonCDMX" }] -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = [ "pillow>=10.3", - "fastapi>=0.115", + "fastapi[all]>=0.115", "uvicorn[standard]>=0.30", "pydantic>=2.7", "httpx>=0.27", + "requests>=2.31", "typer>=0.12", "python-dotenv>=1.0", - "segno>=1.6.1" + "segno>=1.6.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "black>=23.0", + "ruff>=0.1.0", + "mypy>=1.0", + "pre-commit>=3.0", + "isort>=5.12", + "bandit>=1.7" ] [project.scripts] -bannerforge = "bannerforge.cli:app" \ No newline at end of file +bannerforge = "bannerforge.cli:app" + +# Ruff configuration +[tool.ruff] +target-version = "py312" +line-length = 88 +exclude = [ + ".git", + ".venv", + "__pycache__", + "build", + "dist", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "PTH", # flake8-use-pathlib + "ERA", # eradicate + "PL", # pylint + "RUF", # ruff-specific rules +] +ignore = [ + "E501", # line too long, handled by black + "PLR0913", # too many arguments to function call + "PLR0912", # too many branches + "PLR2004", # magic value used in comparison +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ARG", "S101"] # Allow unused args and assert in tests + +[tool.ruff.lint.isort] +known-first-party = ["bannerforge"] + + +# MyPy configuration +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true +show_error_codes = true + +[[tool.mypy.overrides]] +module = [ + "PIL.*", + "segno.*", + "pydantic.*", + "dotenv.*", + "typer.*", + "fastapi.*", + "requests.*", +] +ignore_missing_imports = true + +# Pytest configuration +[tool.pytest.ini_options] +minversion = "7.0" +addopts = [ + "--strict-markers", + "--strict-config", + "--cov=src/bannerforge", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-report=xml", +] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +# Coverage configuration +[tool.coverage.run] +source = ["src"] +omit = [ + "*/tests/*", + "*/test_*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +# Bandit configuration +[tool.bandit] +exclude_dirs = ["tests", ".venv"] +skips = ["B101"] # Skip assert_used test diff --git a/src/bannerforge/__init__.py b/src/bannerforge/__init__.py index 8a73788..6f99a42 100644 --- a/src/bannerforge/__init__.py +++ b/src/bannerforge/__init__.py @@ -2,4 +2,4 @@ "generate_banner", ] -from .renderer import generate_banner \ No newline at end of file +from .renderer import generate_banner diff --git a/src/bannerforge/__main__.py b/src/bannerforge/__main__.py new file mode 100644 index 0000000..a82b23d --- /dev/null +++ b/src/bannerforge/__main__.py @@ -0,0 +1,6 @@ +"""Allow running bannerforge as a module with python -m bannerforge.""" + +from .cli import app + +if __name__ == "__main__": + app() diff --git a/src/bannerforge/api/server.py b/src/bannerforge/api/server.py index dbd4acb..0a8d15f 100644 --- a/src/bannerforge/api/server.py +++ b/src/bannerforge/api/server.py @@ -1,17 +1,26 @@ from __future__ import annotations + +import io +from typing import TYPE_CHECKING + from fastapi import FastAPI, HTTPException from fastapi.responses import Response -from ..models import GenerateRequest + +if TYPE_CHECKING: + from ..models import GenerateRequest + from ..renderer import generate_banner app = FastAPI(title="BannerForge") -@app.get("/health") -def health(): + +@app.get("/health") # type: ignore[misc] +def health() -> dict[str, bool]: return {"ok": True} -@app.post("/api/generate") -def api_generate(payload: GenerateRequest): + +@app.post("/api/generate") # type: ignore[misc] +def api_generate(payload: GenerateRequest) -> Response: try: w, h = _parse_size(payload.size) img = generate_banner( @@ -33,8 +42,8 @@ def api_generate(payload: GenerateRequest): palette_from=payload.palette_from, ) except Exception as e: - raise HTTPException(400, str(e)) - import io + raise HTTPException(400, str(e)) from e + buf = io.BytesIO() img.convert("RGB").save(buf, format="PNG", optimize=True) return Response(content=buf.getvalue(), media_type="image/png") @@ -45,4 +54,4 @@ def _parse_size(s: str) -> tuple[int, int]: w, h = s.lower().split("x") return int(w), int(h) except Exception: - return 1600, 900 \ No newline at end of file + return 1600, 900 diff --git a/src/bannerforge/cli.py b/src/bannerforge/cli.py index b7a95d1..1bb5c51 100644 --- a/src/bannerforge/cli.py +++ b/src/bannerforge/cli.py @@ -1,32 +1,77 @@ from __future__ import annotations + +from typing import Annotated + import typer + from .renderer import generate_banner -app = typer.Typer(add_completion=False, help="Generate event banners") - -@app.command() -def main( - speaker: str = typer.Option(..., help="Speaker name"), - title: str = typer.Option(..., help="Talk title"), - date: str = typer.Option(..., help="Date string"), - venue: str = typer.Option(..., help="Venue string"), - photo: str | None = typer.Option(None, help="URL or path to speaker photo"), - logo: str | None = typer.Option(None, help="URL or path to logo"), - hashtag: str | None = typer.Option(None, help="Hashtag or short tagline"), - style: str = typer.Option("arc", help="Style"), - palette: str = typer.Option("emerald", help="Palette"), - size: str = typer.Option("1080x1080", help="Canvas size WxH"), - output: str = typer.Option("out/banner.png", help="Output PNG path"), - pdf: str | None = typer.Option(None, help="Optional PDF output path"), - site_url: str | None = typer.Option(None, help="Site URL (default pythoncdmx.org)"), - telegram_url: str | None = typer.Option(None, help="Telegram URL (default t.me/PythonCDMX)"), - palette_from: str | None = typer.Option(None, help="Image to extract palette"), - qr_data: str | None = typer.Option(None, help="Data for QR (defaults to site_url)"), - qr_ecc: str = typer.Option("M", help="QR ECC L/M/Q/H"), - qr_scale: int = typer.Option(8, help="QR scale (px per module)"), +__version__ = "0.2.0" + +app = typer.Typer( + add_completion=False, + help="🎨 BannerForge - Generate stylish event banners with QR codes", + rich_markup_mode="rich", +) + + +def version_callback(value: bool) -> None: + """Show version and exit.""" + if value: + typer.echo(f"BannerForge v{__version__}") + raise typer.Exit() + + +@app.command() # type: ignore[misc] +def main( # type: ignore[no-untyped-def] + # Core required fields + speaker: Annotated[str, typer.Option(help="Speaker name")] = ..., # type: ignore[assignment] + title: Annotated[str, typer.Option(help="Talk title")] = ..., # type: ignore[assignment] + date: Annotated[str, typer.Option(help="Date string")] = ..., # type: ignore[assignment] + venue: Annotated[str, typer.Option(help="Venue string")] = ..., # type: ignore[assignment] + # Optional media + photo: Annotated[ + str | None, typer.Option(help="URL or path to speaker photo") + ] = None, + logo: Annotated[str | None, typer.Option(help="URL or path to logo")] = None, + hashtag: Annotated[ + str | None, typer.Option(help="Hashtag or short tagline") + ] = None, + # Style options + style: Annotated[str, typer.Option(help="Style preset")] = "arc", + palette: Annotated[str, typer.Option(help="Color palette")] = "emerald", + size: Annotated[str, typer.Option(help="Canvas size WxH")] = "1080x1080", + # Output options + output: Annotated[str, typer.Option(help="Output PNG path")] = "out/banner.png", + pdf: Annotated[str | None, typer.Option(help="Optional PDF output path")] = None, + # URLs + site_url: Annotated[ + str | None, typer.Option(help="Site URL (default pythoncdmx.org)") + ] = None, + telegram_url: Annotated[ + str | None, typer.Option(help="Telegram URL (default t.me/PythonCDMX)") + ] = None, + # Palette extraction + palette_from: Annotated[ + str | None, typer.Option(help="Image to extract palette from") + ] = None, + # QR options + qr_data: Annotated[ + str | None, typer.Option(help="Data for QR (defaults to site_url)") + ] = None, + qr_ecc: Annotated[ + str, typer.Option(help="QR error correction level: L/M/Q/H") + ] = "M", + qr_scale: Annotated[int, typer.Option(help="QR scale (pixels per module)")] = 8, + # Version + _version: Annotated[ + bool | None, + typer.Option("--version", callback=version_callback, help="Show version"), + ] = None, ): + """Generate an event banner with the specified parameters.""" w, h = _parse_size(size) - img = generate_banner( + generate_banner( speaker_name=speaker, talk_title=title, date=date, @@ -56,5 +101,6 @@ def _parse_size(s: str) -> tuple[int, int]: except Exception: return 1600, 900 + if __name__ == "__main__": - app() \ No newline at end of file + app() diff --git a/src/bannerforge/config.py b/src/bannerforge/config.py index 3864854..cc18974 100644 --- a/src/bannerforge/config.py +++ b/src/bannerforge/config.py @@ -1,9 +1,12 @@ from __future__ import annotations + import os +from pathlib import Path + from dotenv import load_dotenv load_dotenv() CACHE_DIR = os.getenv("BANNERFORGE_CACHE", ".cache") -os.makedirs(CACHE_DIR, exist_ok=True) +Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) DEFAULT_SITE = "pythoncdmx.org" -DEFAULT_TELEGRAM = "https://t.me/PythonCDMX" \ No newline at end of file +DEFAULT_TELEGRAM = "https://t.me/PythonCDMX" diff --git a/src/bannerforge/fonts.py b/src/bannerforge/fonts.py index d715b91..f051472 100644 --- a/src/bannerforge/fonts.py +++ b/src/bannerforge/fonts.py @@ -1,6 +1,12 @@ from __future__ import annotations -import os -from PIL import ImageFont + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from PIL.ImageFont import FreeTypeFont + +from PIL import ImageFont as _ImageFont # CommitMono Nerd Font primary (override with assets/fonts) DEFAULT_TITLE_FONTS = [ @@ -8,35 +14,36 @@ "assets/fonts/CommitMono/CommitMonoNerdFont-Bold.otf", "assets/fonts/CommitMono/CommitMonoNerdFontMono-Bold.otf", "assets/fonts/DejaVuSans/DejaVuSans.ttf", - "assets/fonts/DejaVuSans/DejaVuSans-Bold.ttf" + "assets/fonts/DejaVuSans/DejaVuSans-Bold.ttf", ] DEFAULT_TEXT_FONTS = [ "assets/fonts/CommitMono/CommitMonoNerdFontMono-Regular.otf", "assets/fonts/CommitMono/CommitMonoNerdFont-Bold.otf", "assets/fonts/CommitMono/CommitMonoNerdFont-Regular.otf", "assets/fonts/DejaVuSans/DejaVuSans-Bold.ttf", - "assets/fonts/DejaVuSans/DejaVuSans.ttf" + "assets/fonts/DejaVuSans/DejaVuSans.ttf", ] -ASSET_FONT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "assets", "fonts") +ASSET_FONT_DIR = Path(__file__).parent.parent.parent / "assets" / "fonts" -def safe_font(paths, size: int): - candidates = [] - if isinstance(paths, (list, tuple)): +def safe_font(paths: str | list[str] | tuple[str, ...], size: int) -> FreeTypeFont: + """Load a font safely with fallbacks.""" + candidates: list[str] = [] + if isinstance(paths, list | tuple): candidates.extend(paths) elif isinstance(paths, str): candidates.append(paths) # Asset fonts (project-level override) - if os.path.isdir(ASSET_FONT_DIR): - for fn in os.listdir(ASSET_FONT_DIR): - if fn.lower().endswith(".ttf"): - p = os.path.join(ASSET_FONT_DIR, fn) - if "bold" in fn.lower(): - candidates.insert(0, p) + if ASSET_FONT_DIR.is_dir(): + for font_file in ASSET_FONT_DIR.iterdir(): + if font_file.suffix.lower() == ".ttf": + font_path = str(font_file) + if "bold" in font_file.name.lower(): + candidates.insert(0, font_path) else: - candidates.append(p) + candidates.append(font_path) # System fallbacks candidates += [ @@ -46,10 +53,14 @@ def safe_font(paths, size: int): "/System/Library/Fonts/Supplemental/Arial.ttf", ] - for p in candidates + list(paths if isinstance(paths, (list, tuple)) else [paths]): + for font_path in candidates + list( + paths if isinstance(paths, list | tuple) else [paths] + ): try: - if isinstance(p, str) and os.path.exists(p): - return ImageFont.truetype(p, size=size) - except Exception: - pass - return ImageFont.load_default() \ No newline at end of file + if isinstance(font_path, str) and Path(font_path).exists(): + return _ImageFont.truetype(font_path, size=size) + except OSError as err: # Font loading errors + raise RuntimeError( + "Cannot load any TrueType font. Make sure at least one TrueType font exists." + ) from err + return cast("FreeTypeFont", _ImageFont.load_default()) diff --git a/src/bannerforge/models.py b/src/bannerforge/models.py index 1861d61..2e28306 100644 --- a/src/bannerforge/models.py +++ b/src/bannerforge/models.py @@ -1,25 +1,28 @@ from __future__ import annotations + from pydantic import BaseModel, Field -from typing import Optional + class GenerateRequest(BaseModel): speaker: str title: str date: str venue: str - photo: Optional[str] = None - logo: Optional[str] = None - hashtag: Optional[str] = None - style: str = Field(default="arc", pattern="^(gradient|split|card|arc|pill-left|glass)$") + photo: str | None = None + logo: str | None = None + hashtag: str | None = None + style: str = Field( + default="arc", pattern="^(gradient|split|card|arc|pill-left|glass)$" + ) palette: str = Field(default="emerald") size: str = Field(default="1080x1080") # QR - qr_data: Optional[str] = None # if None -> uses site_url default + qr_data: str | None = None # if None -> uses site_url default qr_ecc: str = Field(default="M", pattern="^[LMQH]$") qr_scale: int = Field(default=8, ge=1, le=30) # Links / palette - site_url: Optional[str] = None # default set in renderer - telegram_url: Optional[str] = None # default set in renderer - palette_from: Optional[str] = None \ No newline at end of file + site_url: str | None = None # default set in renderer + telegram_url: str | None = None # default set in renderer + palette_from: str | None = None diff --git a/src/bannerforge/renderer.py b/src/bannerforge/renderer.py index 3e7c674..afd73a3 100644 --- a/src/bannerforge/renderer.py +++ b/src/bannerforge/renderer.py @@ -1,22 +1,33 @@ from __future__ import annotations -import io, os -from typing import Optional, Tuple + +import io +from pathlib import Path +from typing import Protocol + +import requests +import segno from PIL import Image, ImageDraw, ImageFilter, ImageOps + +from .config import DEFAULT_SITE, DEFAULT_TELEGRAM +from .fonts import DEFAULT_TEXT_FONTS, DEFAULT_TITLE_FONTS, safe_font from .styles import PALETTES, palette_from_image -from .fonts import safe_font, DEFAULT_TITLE_FONTS, DEFAULT_TEXT_FONTS from .textfit import auto_shrink_text -from .config import DEFAULT_SITE, DEFAULT_TELEGRAM -import segno MARGIN = 48 # safety margin + +class StyleProtocol(Protocol): + secondary: tuple[int, int, int] # or Tuple[int, int, int, int] if RGBA + + # local loader -def _load_image_sync(source: Optional[str], expected_size: Optional[Tuple[int, int]] = None): +def _load_image_sync( + source: str | None, expected_size: tuple[int, int] | None = None +) -> Image.Image | None: if not source: return None try: if source.startswith("http://") or source.startswith("https://"): - import requests r = requests.get(source, timeout=15) r.raise_for_status() img = Image.open(io.BytesIO(r.content)).convert("RGBA") @@ -39,12 +50,21 @@ def circular_crop(img: Image.Image, diameter: int) -> Image.Image: return out -def shadow(image: Image.Image, radius: int = 20, opacity: int = 160, offset: tuple[int, int] = (0, 8)) -> Image.Image: +def shadow( + image: Image.Image, + radius: int = 20, + opacity: int = 160, + offset: tuple[int, int] = (0, 8), +) -> Image.Image: alpha = image.split()[-1] sh = Image.new("RGBA", image.size, (0, 0, 0, 0)) sh.putalpha(alpha) sh = sh.filter(ImageFilter.GaussianBlur(radius)) - canvas = Image.new("RGBA", (image.size[0] + abs(offset[0]) * 2, image.size[1] + abs(offset[1]) * 2), (0, 0, 0, 0)) + canvas = Image.new( + "RGBA", + (image.size[0] + abs(offset[0]) * 2, image.size[1] + abs(offset[1]) * 2), + (0, 0, 0, 0), + ) tint = Image.new("RGBA", sh.size, (0, 0, 0, opacity)) tint.putalpha(sh.split()[-1]) canvas.alpha_composite(tint, (offset[0], offset[1])) @@ -52,7 +72,15 @@ def shadow(image: Image.Image, radius: int = 20, opacity: int = 160, offset: tup return canvas -def _draw_urls(draw: ImageDraw.ImageDraw, left: int, y: int, max_w: int, sty, site_url: str | None, telegram_url: str | None): +def _draw_urls( + draw: ImageDraw.ImageDraw, + left: int, + y: int, + _max_w: int, # Unused but kept for API compatibility + sty: StyleProtocol, + site_url: str | None, + telegram_url: str | None, +) -> int: if not site_url and not telegram_url: return y meta_font = safe_font(DEFAULT_TEXT_FONTS, 28) @@ -69,34 +97,38 @@ def _qr_image(data: str, scale: int = 8, ecc: str = "M") -> Image.Image: return Image.open(buf).convert("RGBA") -def generate_banner( +def generate_banner( # noqa: PLR0915 speaker_name: str, talk_title: str, date: str, venue: str, - photo: Optional[str] = None, - logo: Optional[str] = None, - hashtag: Optional[str] = None, + photo: str | None = None, + logo: str | None = None, + hashtag: str | None = None, style: str = "arc", palette: str = "emerald", size: tuple[int, int] = (1080, 1080), - output: Optional[str] = None, - output_pdf: Optional[str] = None, + output: str | None = None, + output_pdf: str | None = None, # QR & links - qr_data: Optional[str] = None, + qr_data: str | None = None, qr_ecc: str = "M", qr_scale: int = 8, - site_url: Optional[str] = None, - telegram_url: Optional[str] = None, - palette_from: Optional[str] = None, + site_url: str | None = None, + telegram_url: str | None = None, + palette_from: str | None = None, ) -> Image.Image: W, H = size - sty = PALETTES.get(palette, PALETTES["emerald"]) if not palette_from else palette_from_image("custom", palette_from) + sty = ( + PALETTES.get(palette, PALETTES["emerald"]) + if not palette_from + else palette_from_image("custom", palette_from) + ) site_url = site_url or DEFAULT_SITE telegram_url = telegram_url or DEFAULT_TELEGRAM - canvas = Image.new("RGBA", (W, H), sty.bg + (255,)) + canvas = Image.new("RGBA", (W, H), (*sty.bg, 255)) draw = ImageDraw.Draw(canvas) # Styles @@ -105,7 +137,8 @@ def generate_banner( bottom = Image.new("RGBA", (W, H), (*sty.accent, 255)) grad = Image.linear_gradient("L").resize((1, H)).resize((W, H)) bg = Image.composite(top, bottom, grad).filter(ImageFilter.GaussianBlur(30)) - canvas = Image.alpha_composite(canvas, bg.putalpha(220) or bg) + bg.putalpha(220) + canvas = Image.alpha_composite(canvas, bg) draw = ImageDraw.Draw(canvas) elif style == "split": draw.rectangle((0, 0, int(W * 0.42), H), fill=(*sty.primary, 255)) @@ -119,15 +152,29 @@ def generate_banner( arc_draw = ImageDraw.Draw(arc_img) arc_draw.ellipse((0, 0, arc_w, arc_w), fill=(*sty.primary, 255)) hole = int(arc_w * 0.75) - arc_draw.ellipse(((arc_w - hole)//2, (arc_w - hole)//2, (arc_w + hole)//2, (arc_w + hole)//2), fill=(0, 0, 0, 0)) - canvas.alpha_composite(arc_img, (W - arc_w - 40, H - arc_w//2)) + arc_draw.ellipse( + ( + (arc_w - hole) // 2, + (arc_w - hole) // 2, + (arc_w + hole) // 2, + (arc_w + hole) // 2, + ), + fill=(0, 0, 0, 0), + ) + canvas.alpha_composite(arc_img, (W - arc_w - 40, H - arc_w // 2)) elif style == "pill-left": - pill = Image.new("RGBA", (int(W*0.56), H - 2*MARGIN), (0,0,0,0)) - ImageDraw.Draw(pill).rounded_rectangle((0,0,pill.width,pill.height), radius=48, fill=(*sty.primary, 255)) + pill = Image.new("RGBA", (int(W * 0.56), H - 2 * MARGIN), (0, 0, 0, 0)) + ImageDraw.Draw(pill).rounded_rectangle( + (0, 0, pill.width, pill.height), radius=48, fill=(*sty.primary, 255) + ) canvas.alpha_composite(pill, (MARGIN, MARGIN)) elif style == "glass": - frosted = Image.new("RGBA", (W - 2*MARGIN, H - 2*MARGIN), (255,255,255,48)) - ImageDraw.Draw(frosted).rounded_rectangle((0,0,frosted.width,frosted.height), radius=32, fill=(255,255,255,48)) + frosted = Image.new( + "RGBA", (W - 2 * MARGIN, H - 2 * MARGIN), (255, 255, 255, 48) + ) + ImageDraw.Draw(frosted).rounded_rectangle( + (0, 0, frosted.width, frosted.height), radius=32, fill=(255, 255, 255, 48) + ) canvas.alpha_composite(frosted, (MARGIN, MARGIN)) # Photo @@ -137,13 +184,15 @@ def generate_banner( area = (int(W * 0.62), MARGIN, W - MARGIN, H - MARGIN) aw, ah = area[2] - area[0], area[3] - area[1] aimg = ImageOps.fit(avatar, (aw, ah), Image.LANCZOS) - aimg = Image.alpha_composite(aimg, Image.new("RGBA", (aw, ah), (0,0,0,30))) + aimg = Image.alpha_composite( + aimg, Image.new("RGBA", (aw, ah), (0, 0, 0, 30)) + ) canvas.alpha_composite(aimg, (area[0], area[1])) elif style == "arc": diameter = int(min(W, H) * 0.50) circ = circular_crop(avatar, diameter) circ = shadow(circ, radius=22, opacity=130, offset=(0, 10)) - canvas.alpha_composite(circ, (int(W*0.52), int(H*0.36))) + canvas.alpha_composite(circ, (int(W * 0.52), int(H * 0.36))) else: diameter = int(min(W, H) * 0.25) circ = circular_crop(avatar, diameter) @@ -158,48 +207,83 @@ def generate_banner( content_left = int(W * 0.08) title_max_w = int(W * 0.52) if style in {"split", "pill-left"} else int(W * 0.6) - title_font, title_txt = auto_shrink_text(draw, talk_title, DEFAULT_TITLE_FONTS, title_max_w, max_font=96, min_font=40) + title_font, title_txt = auto_shrink_text( + draw, talk_title, DEFAULT_TITLE_FONTS, title_max_w, max_font=96, min_font=40 + ) title_y = int(H * 0.18) - draw.multiline_text((content_left, title_y), title_txt, font=title_font, fill=(255,255,255), spacing=6) + draw.multiline_text( + (content_left, title_y), + title_txt, + font=title_font, + fill=(255, 255, 255), + spacing=6, + ) speaker_font = safe_font(DEFAULT_TEXT_FONTS, 44) - draw.text((content_left, title_y + title_font.size * (title_txt.count('\n') + 1) + 18), speaker_name, font=speaker_font, fill=sty.secondary) + draw.text( + (content_left, title_y + title_font.size * (title_txt.count("\n") + 1) + 18), + speaker_name, + font=speaker_font, + fill=sty.secondary, + ) meta_font = safe_font(DEFAULT_TEXT_FONTS, 34) - meta_y = title_y + title_font.size * (title_txt.count('\n') + 1) + 18 + speaker_font.size + 14 - draw.text((content_left, meta_y), f"{date} • {venue}", font=meta_font, fill=sty.secondary) + meta_y = ( + title_y + + title_font.size * (title_txt.count("\n") + 1) + + 18 + + speaker_font.size + + 14 + ) + draw.text( + (content_left, meta_y), + f"{date} • {venue}", + font=meta_font, + fill=sty.secondary, + ) row_y = meta_y + meta_font.size + 24 if hashtag: tag_font = safe_font(DEFAULT_TEXT_FONTS, 30) tw = int(draw.textlength(hashtag, font=tag_font)) + 28 th = tag_font.size + 20 - draw.rounded_rectangle((content_left, row_y, content_left + tw, row_y + th), radius=th // 2, fill=(sty.accent[0], sty.accent[1], sty.accent[2], 220)) - draw.text((content_left + 14, row_y + (th - tag_font.size) // 2 - 2), hashtag, font=tag_font, fill=(255, 255, 255)) + draw.rounded_rectangle( + (content_left, row_y, content_left + tw, row_y + th), + radius=th // 2, + fill=(sty.accent[0], sty.accent[1], sty.accent[2], 220), + ) + draw.text( + (content_left + 14, row_y + (th - tag_font.size) // 2 - 2), + hashtag, + font=tag_font, + fill=(255, 255, 255), + ) row_y += th + 18 - row_y = _draw_urls(draw, content_left, row_y, title_max_w, sty, site_url, telegram_url) + row_y = _draw_urls( + draw, content_left, row_y, title_max_w, sty, site_url, telegram_url + ) # QR (auto from qr_data or site) qr_payload = qr_data or site_url if qr_payload: qr_img = _qr_image(qr_payload, scale=qr_scale, ecc=qr_ecc) - qr_img.thumbnail((int(W*0.14), int(H*0.2)), Image.LANCZOS) - canvas.alpha_composite(qr_img, (W - qr_img.width - MARGIN, int(H*0.12))) + qr_img.thumbnail((int(W * 0.14), int(H * 0.2)), Image.LANCZOS) + canvas.alpha_composite(qr_img, (W - qr_img.width - MARGIN, int(H * 0.12))) # Logo bottom-left (fixed) if logo: logo_img = _load_image_sync(logo) if logo_img is not None: - logo_img.thumbnail((int(W*0.22), int(H*0.16)), Image.LANCZOS) + logo_img.thumbnail((int(W * 0.22), int(H * 0.16)), Image.LANCZOS) canvas.alpha_composite(logo_img, (MARGIN, H - logo_img.height - MARGIN)) draw.rectangle((0, H - 8, W, H), fill=(*sty.primary, 255)) if output: - os.makedirs(os.path.dirname(output) or '.', exist_ok=True) + Path(output).parent.mkdir(parents=True, exist_ok=True) canvas.convert("RGB").save(output, format="PNG", optimize=True) if output_pdf: - os.makedirs(os.path.dirname(output_pdf) or '.', exist_ok=True) + Path(output_pdf).parent.mkdir(parents=True, exist_ok=True) canvas.convert("RGB").save(output_pdf, format="PDF") - return canvas \ No newline at end of file + return canvas diff --git a/src/bannerforge/styles.py b/src/bannerforge/styles.py index b92f08d..69516b0 100644 --- a/src/bannerforge/styles.py +++ b/src/bannerforge/styles.py @@ -1,43 +1,73 @@ from __future__ import annotations + from dataclasses import dataclass -from typing import Tuple, Dict +from typing import Any + from PIL import Image -from .fonts import DEFAULT_TITLE_FONTS, DEFAULT_TEXT_FONTS + +from .fonts import DEFAULT_TEXT_FONTS, DEFAULT_TITLE_FONTS + @dataclass class Style: name: str - bg: Tuple[int, int, int] - primary: Tuple[int, int, int] - secondary: Tuple[int, int, int] - accent: Tuple[int, int, int] + bg: tuple[int, int, int] + primary: tuple[int, int, int] + secondary: tuple[int, int, int] + accent: tuple[int, int, int] title_fonts: tuple[str, ...] = tuple(DEFAULT_TITLE_FONTS) text_fonts: tuple[str, ...] = tuple(DEFAULT_TEXT_FONTS) -def _quantize_palette(path: str, k: int = 6) -> list[Tuple[int, int, int]]: +def _quantize_palette(path: str, k: int = 6) -> list[tuple[int, int, int]]: img = Image.open(path).convert("RGB") pal = img.convert("P", palette=Image.ADAPTIVE, colors=k) - palette = pal.getpalette()[: k * 3] - colors = [(palette[i], palette[i + 1], palette[i + 2]) for i in range(0, len(palette), 3)] - def lum(c): + palette_data = pal.getpalette() + if palette_data is None: + raise ValueError("Image has no palette") + palette = palette_data[: k * 3] + + colors = [ + (palette[i], palette[i + 1], palette[i + 2]) for i in range(0, len(palette), 3) + ] + + def lum(c: tuple) -> Any: r, g, b = [x / 255 for x in c] - return 0.2126*r + 0.7152*g + 0.0722*b + return 0.2126 * r + 0.7152 * g + 0.0722 * b + colors.sort(key=lum, reverse=True) return colors -PALETTES: Dict[str, Style] = { - "emerald": Style("emerald", bg=(16,24,32), primary=(16,185,129), secondary=(209,213,219), accent=(99,102,241)), - "sunset": Style("sunset", bg=(20,20,28), primary=(244,114,182), secondary=(253,186,116), accent=(59,130,246)), - "mono": Style("mono", bg=(18,18,18), primary=(255,255,255), secondary=(156,163,175), accent=(99,102,241)), +PALETTES: dict[str, Style] = { + "emerald": Style( + "emerald", + bg=(16, 24, 32), + primary=(16, 185, 129), + secondary=(209, 213, 219), + accent=(99, 102, 241), + ), + "sunset": Style( + "sunset", + bg=(20, 20, 28), + primary=(244, 114, 182), + secondary=(253, 186, 116), + accent=(59, 130, 246), + ), + "mono": Style( + "mono", + bg=(18, 18, 18), + primary=(255, 255, 255), + secondary=(156, 163, 175), + accent=(99, 102, 241), + ), } def palette_from_image(name: str, path: str) -> Style: colors = _quantize_palette(path, 6) primary = colors[0] - accent = colors[1] if len(colors) > 1 else (99,102,241) - bg = colors[-1] if len(colors) > 2 else (20,20,20) - secondary = colors[2] if len(colors) > 2 else (209,213,219) - return Style(name=name, bg=bg, primary=primary, secondary=secondary, accent=accent) \ No newline at end of file + accent = colors[1] if len(colors) > 1 else (99, 102, 241) + bg = colors[-1] if len(colors) > 2 else (20, 20, 20) + secondary = colors[2] if len(colors) > 2 else (209, 213, 219) + return Style(name=name, bg=bg, primary=primary, secondary=secondary, accent=accent) diff --git a/src/bannerforge/textfit.py b/src/bannerforge/textfit.py index 8a21590..83c8fb5 100644 --- a/src/bannerforge/textfit.py +++ b/src/bannerforge/textfit.py @@ -1,16 +1,33 @@ from __future__ import annotations + import textwrap -from PIL import ImageDraw, ImageFont -from .fonts import safe_font, DEFAULT_TITLE_FONTS +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PIL import ImageDraw, ImageFont + +from .fonts import DEFAULT_TITLE_FONTS, safe_font -def auto_shrink_text(draw: ImageDraw.ImageDraw, text: str, font_paths, max_width: int, max_font: int, min_font: int) -> tuple[ImageFont.ImageFont, str]: +def auto_shrink_text( + draw: ImageDraw.ImageDraw, + text: str, + font_paths: str | list[str] | tuple[str, ...], + max_width: int, + max_font: int, + min_font: int, +) -> tuple[ImageFont.FreeTypeFont, str]: + """Auto-shrink text to fit within max_width.""" for size in range(max_font, min_font - 1, -2): font = safe_font(font_paths, size) w = draw.textlength(text, font=font) if w <= max_width: return font, text font = safe_font(DEFAULT_TITLE_FONTS, min_font) - wrapper = textwrap.TextWrapper(width=max(10, int(len(text) * max_width / max(1, draw.textlength(text, font=font))))) + wrapper = textwrap.TextWrapper( + width=max( + 10, int(len(text) * max_width / max(1, draw.textlength(text, font=font))) + ) + ) wrapped = "\n".join(wrapper.wrap(text)) - return font, wrapped \ No newline at end of file + return font, wrapped