235 lines
6.3 KiB
Python
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()
|