Files
2026-02-21 10:59:32 +03:30

235 lines
6.3 KiB
Python

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()