alphapil 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
alphapil/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ AlphaPIL - An asynchronous, template-based image generation engine.
3
+
4
+ This package provides a powerful recursive parser for handling nested functions
5
+ in image generation templates, with support for asynchronous operations.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __author__ = "AlphaPIL Team"
10
+
11
+ from .engine import CanvasEngine
12
+ from .interpreter import CanvasInterpreter
13
+ from .modules import AlphaMixin, ShapesMixin, TextMixin, ImagesMixin, UtilsMixin, MaskingMixin
14
+
15
+ __all__ = [
16
+ "CanvasEngine",
17
+ "CanvasInterpreter",
18
+ "AlphaMixin",
19
+ "ShapesMixin",
20
+ "TextMixin",
21
+ "ImagesMixin",
22
+ "UtilsMixin",
23
+ "MaskingMixin"
24
+ ]
alphapil/engine.py ADDED
@@ -0,0 +1,259 @@
1
+ """
2
+ CanvasEngine - High-level canvas operations for AlphaPIL.
3
+
4
+ This module provides the CanvasEngine class that inherits from CanvasInterpreter
5
+ and all module mixins to provide comprehensive image generation capabilities.
6
+ """
7
+
8
+ import io
9
+ from typing import Tuple, Union, Optional
10
+ from PIL import Image, ImageDraw, ImageFont
11
+ from .interpreter import CanvasInterpreter
12
+ from .modules import AlphaMixin, ShapesMixin, TextMixin, ImagesMixin, UtilsMixin, MaskingMixin
13
+
14
+
15
+ class CanvasEngine(CanvasInterpreter, AlphaMixin, ShapesMixin, TextMixin, ImagesMixin, UtilsMixin, MaskingMixin):
16
+ """
17
+ High-level canvas engine that extends CanvasInterpreter with Pillow-based
18
+ image generation capabilities and all module mixins.
19
+ """
20
+
21
+ def __init__(self):
22
+ """Initialize the canvas engine and register built-in functions."""
23
+ # Initialize all parent classes properly
24
+ CanvasInterpreter.__init__(self)
25
+ AlphaMixin.__init__(self)
26
+ ShapesMixin.__init__(self)
27
+ TextMixin.__init__(self)
28
+ ImagesMixin.__init__(self)
29
+ UtilsMixin.__init__(self)
30
+ MaskingMixin.__init__(self)
31
+
32
+ # Initialize mixin states
33
+ self._init_state()
34
+ self._init_text()
35
+
36
+ # Initialize canvas-specific attributes
37
+ self.canvas: Optional[Image.Image] = None
38
+ self.draw: Optional[ImageDraw.Draw] = None
39
+ self.canvas_size: Tuple[int, int] = (0, 0)
40
+
41
+ # Register all built-in functions from modules
42
+ self._register_builtin_functions()
43
+
44
+ async def render_template(self, template_text: str, data: dict = None) -> bytes:
45
+ """
46
+ Render a template with optional data injection (Async).
47
+
48
+ Args:
49
+ template_text: Template content as string
50
+ data: Optional dictionary of variables to set
51
+
52
+ Returns:
53
+ Canvas image as bytes
54
+
55
+ Raises:
56
+ RuntimeError: If template rendering fails
57
+ """
58
+ try:
59
+ # Reset engine state
60
+ self.reset()
61
+
62
+ # Inject data if provided
63
+ if data:
64
+ for key, value in data.items():
65
+ self.set_variable(key, str(value))
66
+
67
+ # Parse template line by line
68
+ lines = template_text.strip().split('\n')
69
+ for line_num, line in enumerate(lines, 1):
70
+ line = line.strip()
71
+
72
+ # Skip comments and empty lines
73
+ if not line or line.startswith('#'):
74
+ continue
75
+
76
+ # Parse and execute the line (awaiting async parse)
77
+ result = await self.parse(line)
78
+
79
+ # Return canvas as bytes
80
+ return self.get_canvas_bytes()
81
+
82
+ except Exception as e:
83
+ raise RuntimeError(f"Template rendering failed: {e}")
84
+
85
+ async def render_template_file(self, template_path: str, data: dict = None) -> bytes:
86
+ """
87
+ Render a template from file with optional data injection (Async).
88
+
89
+ Args:
90
+ template_path: Path to template file
91
+ data: Optional dictionary of variables to set
92
+
93
+ Returns:
94
+ Canvas image as bytes
95
+
96
+ Raises:
97
+ FileNotFoundError: If template file doesn't exist
98
+ RuntimeError: If template rendering fails
99
+ """
100
+ try:
101
+ # Use async file reading if possible, but standard open is fine for text files
102
+ # or could use aiofiles if dependency added. For now sync read is okay.
103
+ with open(template_path, 'r', encoding='utf-8') as f:
104
+ template_text = f.read()
105
+ return await self.render_template(template_text, data)
106
+ except FileNotFoundError:
107
+ raise FileNotFoundError(f"Template file not found: {template_path}")
108
+ except Exception as e:
109
+ raise RuntimeError(f"Template rendering failed: {e}")
110
+
111
+ def _register_builtin_functions(self) -> None:
112
+ """Register all built-in canvas manipulation functions from all modules."""
113
+ # Core canvas functions
114
+ self.register_function("createCanvas", self._create_canvas)
115
+ self.register_function("save", self._save_canvas)
116
+ self.register_function("setVar", self._set_var)
117
+
118
+ # State management functions
119
+ # State management functions - Registered at end to use new implementations
120
+ # self.register_function("setFont", self._set_font)
121
+ # self.register_function("setColor", self._set_color)
122
+ # self.register_function("setStroke", self._set_stroke)
123
+
124
+ # Shape functions from ShapesMixin
125
+ self.register_function("drawRect", self._draw_rect)
126
+ self.register_function("drawCircle", self._draw_circle)
127
+ self.register_function("drawRoundedRect", self._draw_rounded_rect)
128
+ self.register_function("drawLine", self._draw_line)
129
+
130
+ # Text functions from TextMixin
131
+ self.register_function("drawText", self._draw_text)
132
+ self.register_function("drawTextStroke", self._draw_text_stroke)
133
+ self.register_function("drawTextGradient", self._draw_text_gradient)
134
+ self.register_function("toUpper", self._to_upper)
135
+ self.register_function("toLower", self._to_lower)
136
+ self.register_function("toTitle", self._to_title)
137
+ self.register_function("measureText", self._measure_text)
138
+ self.register_function("wrapText", self._wrap_text)
139
+ self.register_function("autoSizeText", self._auto_size_text)
140
+ self.register_function("truncateText", self._truncate_text)
141
+ self.register_function("drawTextMid", self._draw_text_mid)
142
+ self.register_function("drawTextIn", self._draw_text_in)
143
+
144
+ # Image functions from ImagesMixin
145
+ self.register_function("drawImage", self._draw_image)
146
+ self.register_function("useImageAsCanvas", self._use_image_as_canvas)
147
+ self.register_function("imageFilter", self._image_filter)
148
+ self.register_function("clearImageCache", self.clear_image_cache)
149
+
150
+ # Utility functions from UtilsMixin
151
+ self.register_function("math", self._math)
152
+ self.register_function("if", self._if)
153
+ self.register_function("random", self._random)
154
+ self.register_function("getHex", self._get_hex)
155
+ self.register_function("replace", self._replace)
156
+ self.register_function("length", self._length)
157
+ self.register_function("substring", self._substring)
158
+ self.register_function("join", self._join)
159
+ self.register_function("split", self._split)
160
+
161
+ # Masking functions from MaskingMixin
162
+ self.register_function("createLayer", self._create_layer)
163
+ self.register_function("switchLayer", self._switch_layer)
164
+ self.register_function("mergeLayer", self._merge_layer)
165
+ self.register_function("applyMask", self._apply_mask)
166
+
167
+ # State management commands
168
+ self.register_function("setFont", self._cmd_set_font)
169
+ self.register_function("loadFont", self._load_font)
170
+ self.register_function("setColor", self._cmd_set_color)
171
+ self.register_function("setStroke", self._cmd_set_stroke)
172
+
173
+ def _create_canvas(self, width: str, height: str, color: str = "white") -> str:
174
+ """
175
+ Create a new canvas with specified dimensions and background color.
176
+
177
+ Args:
178
+ width: Canvas width as string
179
+ height: Canvas height as string
180
+ color: Background color (default: "white")
181
+
182
+ Returns:
183
+ Confirmation message
184
+ """
185
+ try:
186
+ w = int(width)
187
+ h = int(height)
188
+ self.canvas_size = (w, h)
189
+
190
+ # Parse color using the helper from AlphaMixin
191
+ bg_color = self._get_color(color) or (255, 255, 255)
192
+
193
+ self.canvas = Image.new("RGB", (w, h), bg_color)
194
+ self.draw = ImageDraw.Draw(self.canvas)
195
+ return f"Canvas created: {w}x{h}"
196
+ except ValueError as e:
197
+ raise ValueError(f"Invalid canvas dimensions: {e}")
198
+
199
+ def _set_var(self, name: str, value: str) -> str:
200
+ """
201
+ Set a variable value.
202
+
203
+ Args:
204
+ name: Variable name (without {} wrapper)
205
+ value: Variable value
206
+
207
+ Returns:
208
+ Confirmation message
209
+ """
210
+ self.set_variable(name, value)
211
+ return f"Variable {name} set to {value}"
212
+
213
+ def _save_canvas(self, filename: str = "output.png") -> str:
214
+ """
215
+ Save the current canvas to a file with maximum quality.
216
+ """
217
+ if not self.canvas:
218
+ raise RuntimeError("No canvas to save. Call $createCanvas first.")
219
+
220
+ try:
221
+ # Set high quality parameters for various formats
222
+ save_params = {"optimize": True}
223
+ if filename.lower().endswith(('.jpg', '.jpeg')):
224
+ save_params.update({"quality": 100, "subsampling": 0})
225
+
226
+ self.canvas.save(filename, **save_params)
227
+ return f"Canvas saved as {filename}"
228
+ except Exception as e:
229
+ raise RuntimeError(f"Failed to save canvas: {e}")
230
+
231
+ def get_canvas_bytes(self, format: str = "PNG") -> bytes:
232
+ """
233
+ Get the canvas as bytes with maximum quality.
234
+ """
235
+ if not self.canvas:
236
+ raise RuntimeError("No canvas available. Call $createCanvas first.")
237
+
238
+ img_bytes = io.BytesIO()
239
+
240
+ # Set high quality parameters
241
+ save_params = {"format": format, "optimize": True}
242
+ if format.upper() in ["JPEG", "JPG"]:
243
+ save_params.update({"quality": 100, "subsampling": 0})
244
+
245
+ self.canvas.save(img_bytes, **save_params)
246
+ img_bytes.seek(0)
247
+ return img_bytes.getvalue()
248
+
249
+ def reset(self) -> None:
250
+ """Reset the canvas, drawing context, and state."""
251
+ self.canvas = None
252
+ self.draw = None
253
+ self.canvas_size = (0, 0)
254
+ self._init_state()
255
+ self._init_text()
256
+ if hasattr(self, '_image_cache'):
257
+ self._image_cache.clear()
258
+
259
+ # State management is now handled via AlphaMixin _cmd_* methods
@@ -0,0 +1,342 @@
1
+ """
2
+ CanvasInterpreter - Core recursive parser for AlphaPIL.
3
+
4
+ This module provides the fundamental parsing logic for handling nested functions
5
+ using a while loop and re.search pattern matching.
6
+ """
7
+
8
+ import re
9
+ import asyncio
10
+ import inspect
11
+ from typing import Any, Dict, List, Optional, Callable
12
+
13
+
14
+ class CanvasInterpreter:
15
+ """
16
+ Core interpreter class that handles recursive parsing of nested functions.
17
+
18
+ The parser resolves functions from inside-out using a while loop with re.search
19
+ to find and replace nested function calls until no more patterns remain.
20
+ """
21
+
22
+ def __init__(self):
23
+ """Initialize the interpreter with an empty function registry."""
24
+ self.functions: Dict[str, Callable] = {}
25
+ self.variables: Dict[str, Any] = {}
26
+
27
+ def register_function(self, name: str, func: Callable) -> None:
28
+ """
29
+ Register a function that can be called in templates.
30
+
31
+ Args:
32
+ name: Function name (without $ prefix)
33
+ func: Callable that will be executed when the function is encountered
34
+ """
35
+ self.functions[name] = func
36
+
37
+ def set_variable(self, name: str, value: Any) -> None:
38
+ """
39
+ Set a variable that can be used in templates.
40
+
41
+ Args:
42
+ name: Variable name (without {} wrapper)
43
+ value: Value to assign to the variable
44
+ """
45
+ self.variables[name] = value
46
+
47
+ def _find_innermost_function(self, text: str) -> Optional[re.Match]:
48
+ """
49
+ Find the innermost function call in the text.
50
+
51
+ This regex pattern matches function calls like $functionName[...;...]
52
+ and ensures we find the innermost nested calls first.
53
+
54
+ Args:
55
+ text: Text to search for function calls
56
+
57
+ Returns:
58
+ Match object if function found, None otherwise
59
+ """
60
+ # Pattern matches: $functionName[arguments]
61
+ # Uses negative lookahead to avoid matching nested brackets incorrectly
62
+ pattern = r'\$(\w+)\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]'
63
+ return re.search(pattern, text)
64
+
65
+ def _parse_arguments(self, args_str: str) -> List[str]:
66
+ """
67
+ Parse function arguments, handling quoted strings and semicolon separators.
68
+
69
+ Args:
70
+ args_str: String containing function arguments
71
+
72
+ Returns:
73
+ List of parsed arguments
74
+ """
75
+ if not args_str.strip():
76
+ return []
77
+
78
+ args = []
79
+ current_arg = ""
80
+ in_quotes = False
81
+ quote_char = None
82
+ nesting_depth = 0
83
+
84
+ i = 0
85
+ while i < len(args_str):
86
+ char = args_str[i]
87
+
88
+ if char in ('"', "'") and not in_quotes:
89
+ in_quotes = True
90
+ quote_char = char
91
+ current_arg += char
92
+ elif char == quote_char and in_quotes:
93
+ in_quotes = False
94
+ quote_char = None
95
+ current_arg += char
96
+ elif not in_quotes:
97
+ if char in ('[', '('):
98
+ nesting_depth += 1
99
+ current_arg += char
100
+ elif char in (']', ')'):
101
+ nesting_depth -= 1
102
+ current_arg += char
103
+ elif char == ';' and nesting_depth == 0:
104
+ args.append(current_arg.strip())
105
+ current_arg = ""
106
+ else:
107
+ current_arg += char
108
+ else:
109
+ current_arg += char
110
+
111
+ i += 1
112
+
113
+ # Always append the last argument, even if empty, to ensure correct argument count
114
+ args.append(current_arg.strip())
115
+
116
+ return args
117
+
118
+ def _resolve_variables(self, text: str) -> str:
119
+ """
120
+ Resolve variable placeholders like {variable_name} in the text.
121
+
122
+ Args:
123
+ text: Text containing variable placeholders
124
+
125
+ Returns:
126
+ Text with variables replaced by their values
127
+ """
128
+ def replace_var(match):
129
+ var_name = match.group(1)
130
+ # Support default values: {var|default}
131
+ default_val = ""
132
+ if '|' in var_name:
133
+ var_name, default_val = var_name.split('|', 1)
134
+
135
+ # Use get() with default_val (or empty string if not provided)
136
+ # Note: variables dict values should be strings ideally
137
+ val = self.variables.get(var_name, default_val)
138
+ return str(val) if val is not None else default_val
139
+
140
+ # Match variables with optional |default part: {name} or {name|default}
141
+ # Allow any characters except closing brace inside
142
+ return re.sub(r'\{([^\}]+)\}', replace_var, text)
143
+
144
+ async def _preprocess_argument(self, arg: str) -> str:
145
+ """
146
+ Recursively resolve all nested functions and variables in an argument.
147
+
148
+ Execution order:
149
+ 1. Variable replacement: {var} → value
150
+ 2. Nested function evaluation: $func[...] → result (recursively)
151
+
152
+ This ensures that expressions like $drawRect[10;10;$math[100 * 2];50;red]
153
+ work correctly by evaluating $math[100 * 2] → "200" before passing to _drawRect.
154
+
155
+ Args:
156
+ arg: Argument string that may contain nested functions and variables
157
+
158
+ Returns:
159
+ Fully resolved argument value
160
+ """
161
+ # First pass: resolve variables
162
+ arg = self._resolve_variables(arg)
163
+
164
+ # Second pass: resolve nested functions recursively
165
+ max_iterations = 100 # Prevent infinite loops in nested functions
166
+ iteration = 0
167
+
168
+ while iteration < max_iterations:
169
+ iteration += 1
170
+ match = self._find_innermost_function(arg)
171
+
172
+ if not match:
173
+ break # No more functions to resolve
174
+
175
+ func_name = match.group(1)
176
+ args_str = match.group(2)
177
+
178
+ # Recursively preprocess nested arguments
179
+ nested_args = self._parse_arguments(args_str)
180
+
181
+ # Process nested arguments async
182
+ processed_nested_args = []
183
+ for a in nested_args:
184
+ processed_nested_args.append(await self._preprocess_argument(a))
185
+
186
+ # Execute the function
187
+ if func_name not in self.functions:
188
+ raise ValueError(f"Unknown function: ${func_name}")
189
+
190
+ try:
191
+ result = await self._execute_function(func_name, processed_nested_args)
192
+ # Clean up numeric results
193
+ if func_name in ['math', 'random', 'length', 'getHex']:
194
+ result = str(result).strip()
195
+ else:
196
+ result = str(result)
197
+ except Exception as e:
198
+ raise RuntimeError(f"Error in nested function ${func_name}: {e}")
199
+
200
+ # Replace in original string
201
+ arg = arg[:match.start()] + result + arg[match.end():]
202
+
203
+ if iteration >= max_iterations:
204
+ raise RuntimeError(f"Argument preprocessing exceeded maximum iterations: {arg}")
205
+
206
+ return arg
207
+
208
+ async def _execute_function(self, func_name: str, args: List[str]) -> str:
209
+ """
210
+ Execute a registered function with parsed arguments (Async).
211
+ Supports both sync and async functions, and flexible argument mapping.
212
+
213
+ Args:
214
+ func_name: Name of the function to execute
215
+ args: List of argument strings
216
+
217
+ Returns:
218
+ String result of the function execution
219
+ """
220
+ if func_name not in self.functions:
221
+ raise ValueError(f"Unknown function: ${func_name}")
222
+
223
+ try:
224
+ func = self.functions[func_name]
225
+
226
+ # Split into named and unnamed arguments
227
+ named_args = {}
228
+ unnamed_args = []
229
+
230
+ for arg in args:
231
+ if '=' in arg and not arg.startswith(('http://', 'https://')):
232
+ parts = arg.split('=', 1)
233
+ key = parts[0].strip()
234
+ val = parts[1].strip()
235
+
236
+ # Only treat as named arg if:
237
+ # 1. key is a valid identifier
238
+ # 2. value doesn't start with '=' (avoids '==')
239
+ # 3. key doesn't end with common comparison symbols (avoids '!=', '<=', '>=')
240
+ if key.isidentifier() and not val.startswith('=') and not key.endswith(('!', '<', '>')):
241
+ named_args[key] = val
242
+ continue
243
+ unnamed_args.append(arg)
244
+
245
+ # Use inspect to map arguments to the function signature
246
+ sig = inspect.signature(func)
247
+ params = list(sig.parameters.values())
248
+
249
+ # Build the argument set
250
+ final_kwargs = {}
251
+
252
+ # 1. Apply named arguments
253
+ final_kwargs.update(named_args)
254
+
255
+ # 2. Map unnamed arguments to remaining parameters
256
+ unnamed_idx = 0
257
+ for param in params:
258
+ if param.name not in final_kwargs and unnamed_idx < len(unnamed_args):
259
+ final_kwargs[param.name] = unnamed_args[unnamed_idx]
260
+ unnamed_idx += 1
261
+
262
+ # Execute the function - only pass arguments that exist in the function signature
263
+ safe_kwargs = {k: v for k, v in final_kwargs.items() if k in sig.parameters}
264
+ result = func(**safe_kwargs)
265
+
266
+ # Handle async functions
267
+ if inspect.iscoroutine(result):
268
+ result = await result
269
+
270
+ # Strip whitespace from result for numeric functions
271
+ if func_name in ['math', 'random', 'length', 'getHex']:
272
+ result = str(result).strip()
273
+
274
+ return str(result)
275
+ except Exception as e:
276
+ # Provide more detailed error info for debugging
277
+ raise RuntimeError(f"Error executing function ${func_name} with args {args}: {e}")
278
+
279
+ async def parse(self, template: str) -> str:
280
+ """
281
+ Parse a template string with correct execution order (Async).
282
+
283
+ Execution order:
284
+ 1. Resolve variables: {var} → value
285
+ 2. Find innermost function
286
+ 3. Preprocess arguments (recursively resolve variables + nested functions)
287
+ 4. Execute function with fully resolved arguments
288
+ 5. Replace function call with result
289
+ 6. Repeat until no functions remain
290
+
291
+ This ensures expressions like $drawRect[10;10;$math[100 * 2];50;red]
292
+ work correctly by evaluating nested $math before passing to _drawRect.
293
+
294
+ Args:
295
+ template: Template string containing function calls and variables
296
+
297
+ Returns:
298
+ Fully resolved template string
299
+ """
300
+ result = template
301
+
302
+ # Main parsing loop with iteration limit to prevent infinite loops
303
+ max_iterations = 1000
304
+ iteration = 0
305
+
306
+ while iteration < max_iterations:
307
+ iteration += 1
308
+
309
+ # Step 1: Resolve variables first
310
+ result = self._resolve_variables(result)
311
+
312
+ # Step 2: Find the innermost function call
313
+ match = self._find_innermost_function(result)
314
+
315
+ if not match:
316
+ break # No more function calls to resolve
317
+
318
+ func_name = match.group(1)
319
+ args_str = match.group(2)
320
+
321
+ # Step 3: Parse and preprocess arguments
322
+ # This recursively resolves all variables and nested functions in each argument
323
+ args = self._parse_arguments(args_str)
324
+
325
+ # Process arguments async
326
+ processed_args = []
327
+ for arg in args:
328
+ processed_args.append(await self._preprocess_argument(arg))
329
+
330
+ # Step 4: Execute function with fully resolved arguments
331
+ try:
332
+ func_result = await self._execute_function(func_name, processed_args)
333
+ except Exception as e:
334
+ raise RuntimeError(f"Failed to execute ${func_name}: {e}")
335
+
336
+ # Step 5: Replace the function call with its result
337
+ result = result[:match.start()] + func_result + result[match.end():]
338
+
339
+ if iteration >= max_iterations:
340
+ raise RuntimeError("Parser exceeded maximum iterations - possible infinite loop in template")
341
+
342
+ return result
@@ -0,0 +1,15 @@
1
+ """
2
+ AlphaPIL Modules - Modular functionality for image generation.
3
+
4
+ This package contains modular components that can be easily extended
5
+ by adding new Python files to this directory.
6
+ """
7
+
8
+ from .base import AlphaMixin
9
+ from .shapes import ShapesMixin
10
+ from .text import TextMixin
11
+ from .images import ImagesMixin
12
+ from .utils import UtilsMixin
13
+ from .masking import MaskingMixin
14
+
15
+ __all__ = ["AlphaMixin", "ShapesMixin", "TextMixin", "ImagesMixin", "UtilsMixin", "MaskingMixin"]