Initial project

This commit is contained in:
2026-02-21 10:59:32 +03:30
parent 56ca8c68e6
commit 608e908e34
9 changed files with 647 additions and 0 deletions

234
main.py Normal file
View File

@@ -0,0 +1,234 @@
import argparse
import json
import sys
import re
from pathlib import Path
from typing import List, Literal
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import arabic_reshaper
from bidi.algorithm import get_display
from pydantic import BaseModel, Field, field_validator, ConfigDict, ValidationError
# =======================
# Validation Models
# =======================
HEX_COLOR_REGEX = r"^#([A-Fa-f0-9]{6})$"
class FillModel(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["solid", "gradient"]
color: str | None = None
colors: List[str] | None = None
direction: Literal["vertical", "horizontal"] | None = None
@field_validator("color")
@classmethod
def validate_color(cls, v):
if v and not re.match(HEX_COLOR_REGEX, v):
raise ValueError("Color must be valid HEX like #ff00aa")
return v
@field_validator("colors")
@classmethod
def validate_colors(cls, v):
if v:
for color in v:
if not re.match(HEX_COLOR_REGEX, color):
raise ValueError(f"Invalid gradient color: {color}")
return v
class StrokeModel(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool
width: int = Field(ge=0, le=100)
color: str
@field_validator("color")
@classmethod
def validate_color(cls, v):
if not re.match(HEX_COLOR_REGEX, v):
raise ValueError("Stroke color must be HEX like #000000")
return v
class ShadowModel(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool
offset: List[int]
blur: int = Field(ge=0, le=200)
color: str
opacity: float = Field(ge=0, le=1)
@field_validator("color")
@classmethod
def validate_color(cls, v):
if not re.match(HEX_COLOR_REGEX, v):
raise ValueError("Shadow color must be HEX like #000000")
return v
class StyleModel(BaseModel):
model_config = ConfigDict(extra="forbid")
font_path: str
font_size: int = Field(gt=0, le=500)
center: List[int]
fill: FillModel
stroke: StrokeModel
shadow: ShadowModel
outer_glow: ShadowModel
inner_shadow: ShadowModel
letter_spacing: int = Field(ge=0, le=100)
line_height: float = Field(gt=0, le=5)
rotation: float = Field(ge=-360, le=360)
opacity: float = Field(ge=0, le=1)
# =======================
# Utility Functions
# =======================
def hex_to_rgba(hex_color: str, opacity: float = 1.0):
hex_color = hex_color.lstrip("#")
r, g, b = tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
return (r, g, b, int(255 * opacity))
def reshape_text(text: str) -> str:
reshaped = arabic_reshaper.reshape(text)
return get_display(reshaped)
def create_gradient(size, colors, direction):
width, height = size
gradient = np.zeros((height, width, 4), dtype=np.uint8)
c1 = np.array(hex_to_rgba(colors[0]))
c2 = np.array(hex_to_rgba(colors[1]))
for i in range(height if direction == "vertical" else width):
ratio = i / (height if direction == "vertical" else width)
color = (1 - ratio) * c1 + ratio * c2
if direction == "vertical":
gradient[i, :] = color
else:
gradient[:, i] = color
return Image.fromarray(gradient)
# =======================
# Render Engine
# =======================
def render(image_path: str, output_path: str, text: str, style: StyleModel):
if not Path(style.font_path).exists():
raise FileNotFoundError("Font file not found")
image = Image.open(image_path).convert("RGBA")
text_layer = Image.new("RGBA", image.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(text_layer)
font = ImageFont.truetype(style.font_path, style.font_size)
text = reshape_text(text)
bbox = draw.textbbox((0, 0), text, font=font)
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]
x = style.center[0] - w // 2
y = style.center[1] - h // 2
# Fill
if style.fill.type == "solid":
draw.text((x, y),
text,
font=font,
fill=hex_to_rgba(style.fill.color, style.opacity))
else:
mask = Image.new("L", image.size, 0)
ImageDraw.Draw(mask).text((x, y), text, font=font, fill=255)
gradient = create_gradient(image.size,
style.fill.colors,
style.fill.direction)
text_layer.paste(gradient, (0, 0), mask)
# Stroke
if style.stroke.enabled:
draw.text((x, y),
text,
font=font,
fill=None,
stroke_width=style.stroke.width,
stroke_fill=hex_to_rgba(style.stroke.color))
image.alpha_composite(text_layer)
if style.rotation != 0:
image = image.rotate(style.rotation, expand=True)
image.save(output_path)
print("Rendered successfully →", output_path)
# =======================
# CLI
# =======================
def print_validation_error(e: ValidationError):
print("\n❌ Style configuration error:\n")
for error in e.errors():
field = "".join(str(loc) for loc in error["loc"])
message = error["msg"]
print(f"{field}: {message}")
print("\nPlease fix style.json and try again.\n")
def main():
try:
parser = argparse.ArgumentParser(description="Advanced Text Renderer")
parser.add_argument("--image", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--text", required=True)
parser.add_argument("--style", required=True)
args = parser.parse_args()
if not Path(args.image).exists():
print("❌ Image file not found.")
sys.exit(1)
if not Path(args.style).exists():
print("❌ Style JSON file not found.")
sys.exit(1)
with open(args.style, "r", encoding="utf-8") as f:
style_data = json.load(f)
style = StyleModel(**style_data)
render(args.image, args.output, args.text, style)
except ValidationError as e:
print_validation_error(e)
sys.exit(1)
except Exception as e:
print("\n❌ Unexpected error:", str(e))
sys.exit(1)
if __name__ == "__main__":
main()