cudag 0.3.10__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.
Files changed (69) hide show
  1. cudag/__init__.py +334 -0
  2. cudag/annotation/__init__.py +77 -0
  3. cudag/annotation/codegen.py +648 -0
  4. cudag/annotation/config.py +545 -0
  5. cudag/annotation/loader.py +342 -0
  6. cudag/annotation/scaffold.py +121 -0
  7. cudag/annotation/transcription.py +296 -0
  8. cudag/cli/__init__.py +5 -0
  9. cudag/cli/main.py +315 -0
  10. cudag/cli/new.py +873 -0
  11. cudag/core/__init__.py +364 -0
  12. cudag/core/button.py +137 -0
  13. cudag/core/canvas.py +222 -0
  14. cudag/core/config.py +70 -0
  15. cudag/core/coords.py +233 -0
  16. cudag/core/data_grid.py +804 -0
  17. cudag/core/dataset.py +678 -0
  18. cudag/core/distribution.py +136 -0
  19. cudag/core/drawing.py +75 -0
  20. cudag/core/fonts.py +156 -0
  21. cudag/core/generator.py +163 -0
  22. cudag/core/grid.py +367 -0
  23. cudag/core/grounding_task.py +247 -0
  24. cudag/core/icon.py +207 -0
  25. cudag/core/iconlist_task.py +301 -0
  26. cudag/core/models.py +1251 -0
  27. cudag/core/random.py +130 -0
  28. cudag/core/renderer.py +190 -0
  29. cudag/core/screen.py +402 -0
  30. cudag/core/scroll_task.py +254 -0
  31. cudag/core/scrollable_grid.py +447 -0
  32. cudag/core/state.py +110 -0
  33. cudag/core/task.py +293 -0
  34. cudag/core/taskbar.py +350 -0
  35. cudag/core/text.py +212 -0
  36. cudag/core/utils.py +82 -0
  37. cudag/data/surnames.txt +5000 -0
  38. cudag/modal_apps/__init__.py +4 -0
  39. cudag/modal_apps/archive.py +103 -0
  40. cudag/modal_apps/extract.py +138 -0
  41. cudag/modal_apps/preprocess.py +529 -0
  42. cudag/modal_apps/upload.py +317 -0
  43. cudag/prompts/SYSTEM_PROMPT.txt +104 -0
  44. cudag/prompts/__init__.py +33 -0
  45. cudag/prompts/system.py +43 -0
  46. cudag/prompts/tools.py +382 -0
  47. cudag/py.typed +0 -0
  48. cudag/schemas/filesystem.json +90 -0
  49. cudag/schemas/test_record.schema.json +113 -0
  50. cudag/schemas/train_record.schema.json +90 -0
  51. cudag/server/__init__.py +21 -0
  52. cudag/server/app.py +232 -0
  53. cudag/server/services/__init__.py +9 -0
  54. cudag/server/services/generator.py +128 -0
  55. cudag/templates/scripts/archive.sh +35 -0
  56. cudag/templates/scripts/build.sh +13 -0
  57. cudag/templates/scripts/extract.sh +54 -0
  58. cudag/templates/scripts/generate.sh +116 -0
  59. cudag/templates/scripts/pre-commit.sh +44 -0
  60. cudag/templates/scripts/preprocess.sh +46 -0
  61. cudag/templates/scripts/upload.sh +63 -0
  62. cudag/templates/scripts/verify.py +428 -0
  63. cudag/validation/__init__.py +35 -0
  64. cudag/validation/validate.py +508 -0
  65. cudag-0.3.10.dist-info/METADATA +570 -0
  66. cudag-0.3.10.dist-info/RECORD +69 -0
  67. cudag-0.3.10.dist-info/WHEEL +4 -0
  68. cudag-0.3.10.dist-info/entry_points.txt +2 -0
  69. cudag-0.3.10.dist-info/licenses/LICENSE +66 -0
cudag/core/task.py ADDED
@@ -0,0 +1,293 @@
1
+ # Copyright (c) 2025 Tylt LLC. All rights reserved.
2
+ # CONFIDENTIAL AND PROPRIETARY. Unauthorized use, copying, or distribution
3
+ # is strictly prohibited. For licensing inquiries: hello@claimhawk.app
4
+
5
+ """Base task class and data structures for VLM training samples.
6
+
7
+ Tasks are the "Controller" in VLMGen's Screen/State/Renderer/Task architecture.
8
+ Each task type (click-day, scroll-grid, etc.) defines:
9
+ - How to generate prompts
10
+ - What tool calls are expected
11
+ - How to create state for rendering
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from random import Random
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from cudag.prompts.tools import ToolCall, format_tool_call
23
+
24
+ if TYPE_CHECKING:
25
+ from cudag.core.dataset import DatasetConfig
26
+ from cudag.core.renderer import BaseRenderer
27
+
28
+
29
+ @dataclass
30
+ class TaskSample:
31
+ """Output of a task generation.
32
+
33
+ This represents a single training sample in the dataset.
34
+ """
35
+
36
+ id: str
37
+ """Unique identifier for this sample."""
38
+
39
+ image_path: Path
40
+ """Path to the generated image file."""
41
+
42
+ human_prompt: str
43
+ """The human instruction (without <image> prefix)."""
44
+
45
+ tool_call: ToolCall
46
+ """The expected tool call response."""
47
+
48
+ pixel_coords: tuple[int, int]
49
+ """Pixel coordinates of the target (for real_coords in metadata)."""
50
+
51
+ metadata: dict[str, Any] = field(default_factory=dict)
52
+ """Task-specific metadata (task_type is added automatically)."""
53
+
54
+ image_size: tuple[int, int] = (1000, 1000)
55
+ """Size of the generated image (width, height)."""
56
+
57
+
58
+ @dataclass
59
+ class TestCase:
60
+ """Output of test case generation.
61
+
62
+ This represents a single test case for evaluating model accuracy.
63
+ """
64
+
65
+ test_id: str
66
+ """Unique identifier for this test case."""
67
+
68
+ screenshot: Path
69
+ """Path to the test screenshot."""
70
+
71
+ prompt: str
72
+ """The human instruction (without <image> prefix)."""
73
+
74
+ expected_action: dict[str, Any]
75
+ """Expected tool call as dict (for JSON serialization)."""
76
+
77
+ tolerance: tuple[int, int] | int
78
+ """Allowed coordinate tolerance (x, y) in RU units."""
79
+
80
+ metadata: dict[str, Any] = field(default_factory=dict)
81
+ """Test-specific metadata."""
82
+
83
+ pixel_coords: tuple[int, int] | None = None
84
+ """Original pixel coordinates (before normalization)."""
85
+
86
+
87
+ @dataclass
88
+ class TaskContext:
89
+ """Context passed to task generation methods.
90
+
91
+ Provides access to shared resources like RNG, output directories, and config.
92
+ """
93
+
94
+ rng: Random
95
+ """Seeded random number generator for reproducibility."""
96
+
97
+ index: int
98
+ """Current sample index (for ID generation)."""
99
+
100
+ output_dir: Path
101
+ """Directory for generated images."""
102
+
103
+ config: dict[str, Any]
104
+ """Task-specific configuration."""
105
+
106
+ dataset_name: str
107
+ """Name prefix for generated IDs."""
108
+
109
+
110
+ class BaseTask(ABC):
111
+ """Abstract base class for task types.
112
+
113
+ Subclass this to create new task types. Each task type defines:
114
+ - task_type: Unique identifier (e.g., "click-day", "scroll-grid")
115
+ - generate_samples(): How to generate training samples from one image (1:N)
116
+ - generate_tests(): How to generate test cases from one image (1:N)
117
+
118
+ The key insight is that one rendered image can produce MULTIPLE training
119
+ samples. For example, a claim window image can have:
120
+ - "Click the procedure code" → one coordinate
121
+ - "Click the fee column" → different coordinate
122
+ - "Scroll down in the grid" → scroll action
123
+
124
+ Example:
125
+ class ClaimWindowTask(BaseTask):
126
+ task_type = "claim-window"
127
+
128
+ def generate_samples(self, ctx: TaskContext) -> list[TaskSample]:
129
+ # 1. Generate state and render ONCE
130
+ state = ClaimWindowState.generate(ctx.rng)
131
+ image, metadata = self.renderer.render(state)
132
+ image_path = self.save_image(image, ctx)
133
+
134
+ # 2. Derive MULTIPLE samples from this one image
135
+ samples = []
136
+
137
+ # Sample 1: Click procedure code
138
+ samples.append(TaskSample(
139
+ id=self.build_id(ctx, "_click_code"),
140
+ image_path=image_path,
141
+ human_prompt="Click the procedure code",
142
+ tool_call=ToolCall.left_click(code_coords),
143
+ pixel_coords=code_coords,
144
+ ...
145
+ ))
146
+
147
+ # Sample 2: Click fee
148
+ samples.append(TaskSample(
149
+ id=self.build_id(ctx, "_click_fee"),
150
+ image_path=image_path, # SAME IMAGE
151
+ human_prompt="Click the fee column",
152
+ tool_call=ToolCall.left_click(fee_coords),
153
+ pixel_coords=fee_coords,
154
+ ...
155
+ ))
156
+
157
+ return samples
158
+ """
159
+
160
+ task_type: str
161
+ """Unique identifier for this task type (e.g., 'click-day', 'scroll-grid')."""
162
+
163
+ def __init__(
164
+ self, config: DatasetConfig | dict[str, Any], renderer: BaseRenderer[Any]
165
+ ) -> None:
166
+ """Initialize the task.
167
+
168
+ Args:
169
+ config: Task-specific configuration from generator.yaml (DatasetConfig or dict)
170
+ renderer: Renderer instance for generating images
171
+ """
172
+ self.config = config
173
+ self.renderer = renderer
174
+
175
+ def generate_samples(self, ctx: TaskContext) -> list[TaskSample]:
176
+ """Generate training samples from one rendered image.
177
+
178
+ Override this to generate multiple samples from a single render.
179
+ Default implementation calls generate_sample() once for backwards compat.
180
+
181
+ Args:
182
+ ctx: Task context with RNG, index, output directory, etc.
183
+
184
+ Returns:
185
+ List of TaskSample objects (can share the same image_path).
186
+ """
187
+ return [self.generate_sample(ctx)]
188
+
189
+ @abstractmethod
190
+ def generate_sample(self, ctx: TaskContext) -> TaskSample:
191
+ """Generate one training sample.
192
+
193
+ For simple 1:1 image-to-sample tasks, implement this.
194
+ For 1:N image-to-samples, override generate_samples() instead.
195
+
196
+ Args:
197
+ ctx: Task context with RNG, index, output directory, etc.
198
+
199
+ Returns:
200
+ TaskSample with all required fields populated.
201
+ """
202
+ pass
203
+
204
+ def generate_tests(self, ctx: TaskContext) -> list[TestCase]:
205
+ """Generate test cases from one rendered image.
206
+
207
+ Override this to generate multiple tests from a single render.
208
+ Default implementation calls generate_test() once for backwards compat.
209
+
210
+ Args:
211
+ ctx: Task context with RNG, index, output directory, etc.
212
+
213
+ Returns:
214
+ List of TestCase objects (can share the same screenshot).
215
+ """
216
+ return [self.generate_test(ctx)]
217
+
218
+ @abstractmethod
219
+ def generate_test(self, ctx: TaskContext) -> TestCase:
220
+ """Generate one test case.
221
+
222
+ For simple 1:1 image-to-test tasks, implement this.
223
+ For 1:N image-to-tests, override generate_tests() instead.
224
+
225
+ Args:
226
+ ctx: Task context with RNG, index, output directory, etc.
227
+
228
+ Returns:
229
+ TestCase with all required fields populated.
230
+ """
231
+ pass
232
+
233
+ def format_gpt_response(self, tool_call: ToolCall) -> str:
234
+ """Format the GPT response for this sample.
235
+
236
+ Override this to customize the response format (e.g., add <think> tags).
237
+
238
+ Args:
239
+ tool_call: The tool call to format
240
+
241
+ Returns:
242
+ Formatted string for the "gpt" conversation turn
243
+ """
244
+ return format_tool_call(tool_call)
245
+
246
+ def save_image(
247
+ self,
248
+ image: Any, # PIL.Image.Image
249
+ ctx: TaskContext,
250
+ extension: str = "jpg",
251
+ quality: int = 85,
252
+ prefix: str | None = None,
253
+ ) -> Path:
254
+ """Save a generated image to the output directory.
255
+
256
+ Args:
257
+ image: PIL Image to save
258
+ ctx: Task context
259
+ extension: Image format (default: jpg)
260
+ quality: JPEG quality (ignored for PNG)
261
+ prefix: Optional prefix for filename (e.g., "eval" for eval images)
262
+
263
+ Returns:
264
+ Path to saved image
265
+ """
266
+ images_dir = ctx.output_dir / "images"
267
+ images_dir.mkdir(parents=True, exist_ok=True)
268
+
269
+ if prefix:
270
+ filename = f"{prefix}_{ctx.index:05d}.{extension}"
271
+ else:
272
+ filename = f"{ctx.dataset_name}_{ctx.index:05d}.{extension}"
273
+ path = images_dir / filename
274
+
275
+ if extension.lower() in ("jpg", "jpeg"):
276
+ image.save(path, quality=quality)
277
+ else:
278
+ image.save(path)
279
+
280
+ return path
281
+
282
+ def build_id(self, ctx: TaskContext, suffix: str = "") -> str:
283
+ """Build a sample ID from context.
284
+
285
+ Args:
286
+ ctx: Task context
287
+ suffix: Optional suffix to add (e.g., "_task")
288
+
289
+ Returns:
290
+ Formatted ID string
291
+ """
292
+ base = f"{ctx.dataset_name}_{ctx.index:05d}"
293
+ return f"{base}{suffix}" if suffix else base
cudag/core/taskbar.py ADDED
@@ -0,0 +1,350 @@
1
+ # Copyright (c) 2025 Tylt LLC. All rights reserved.
2
+ # CONFIDENTIAL AND PROPRIETARY. Unauthorized use, copying, or distribution
3
+ # is strictly prohibited. For licensing inquiries: hello@claimhawk.app
4
+
5
+ """Taskbar primitive for Windows-style taskbar rendering.
6
+
7
+ Provides reusable TaskbarState and TaskbarRenderer for any generator
8
+ that needs a taskbar at the bottom of the screen.
9
+
10
+ The taskbar includes:
11
+ - Icons (configurable position, varying N, random order)
12
+ - DateTime display (time + date in Windows format)
13
+
14
+ Example:
15
+ from cudag.core import TaskbarState, TaskbarRenderer
16
+
17
+ # Generate random taskbar state
18
+ state = TaskbarState.generate(
19
+ rng=rng,
20
+ icon_config=annotation_config.get_element_by_label("taskbar"),
21
+ )
22
+
23
+ # Render onto existing image
24
+ renderer = TaskbarRenderer(assets_dir="assets")
25
+ metadata = renderer.render_onto(image, state)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from datetime import date
32
+ from pathlib import Path
33
+ from random import Random
34
+ from typing import Any
35
+
36
+ from PIL import Image, ImageDraw, ImageFont
37
+
38
+ from cudag.core.icon import IconPlacement, IconSpec, TASKBAR_ICON
39
+
40
+
41
+ @dataclass
42
+ class TaskbarState:
43
+ """State for a Windows-style taskbar.
44
+
45
+ Contains icon placements and datetime text for rendering.
46
+ """
47
+
48
+ icons: list[IconPlacement] = field(default_factory=list)
49
+ """Icons placed on the taskbar."""
50
+
51
+ datetime_text: str = ""
52
+ """DateTime string (e.g., '1:30 PM\\n12/15/2025')."""
53
+
54
+ datetime_position: tuple[int, int] = (0, 0)
55
+ """Position for datetime text (x, y)."""
56
+
57
+ @classmethod
58
+ def generate(
59
+ cls,
60
+ rng: Random,
61
+ icon_config: Any | None = None,
62
+ datetime_position: tuple[int, int] = (1868, 1043),
63
+ icon_spec: IconSpec | None = None,
64
+ taskbar_left_margin: int = 946,
65
+ taskbar_y_offset: int = 1042,
66
+ icon_gap: int = 8,
67
+ target_date: date | None = None,
68
+ ) -> "TaskbarState":
69
+ """Generate random taskbar state.
70
+
71
+ Args:
72
+ rng: Random number generator
73
+ icon_config: AnnotatedElement with icons, varyN, randomOrder settings
74
+ datetime_position: Position for datetime text
75
+ icon_spec: Icon specification (defaults to TASKBAR_ICON)
76
+ taskbar_left_margin: X position where icons start
77
+ taskbar_y_offset: Y position for icons
78
+ icon_gap: Gap between icons
79
+ target_date: Specific date to display (for consistent calendar/taskbar)
80
+
81
+ Returns:
82
+ TaskbarState with randomized icons and datetime
83
+ """
84
+ state = cls()
85
+ state.datetime_text = cls._generate_datetime(rng, target_date)
86
+ state.datetime_position = datetime_position
87
+
88
+ if icon_config is not None:
89
+ state.icons = cls._place_icons(
90
+ rng,
91
+ icon_config,
92
+ icon_spec or TASKBAR_ICON,
93
+ taskbar_left_margin,
94
+ taskbar_y_offset,
95
+ icon_gap,
96
+ )
97
+
98
+ return state
99
+
100
+ @classmethod
101
+ def _generate_datetime(cls, rng: Random, target_date: date | None = None) -> str:
102
+ """Generate datetime string in Windows 11 format.
103
+
104
+ Args:
105
+ rng: Random number generator for time component
106
+ target_date: Specific date to use (or None for random)
107
+
108
+ Returns:
109
+ Formatted datetime string like "1:30 PM\\n12/15/2025"
110
+ """
111
+ # Time is always random
112
+ hour = rng.randint(1, 12)
113
+ minute = rng.randint(0, 59)
114
+ am_pm = rng.choice(["AM", "PM"])
115
+
116
+ # Date from target_date or random
117
+ if target_date is not None:
118
+ month = target_date.month
119
+ day = target_date.day
120
+ year = target_date.year
121
+ else:
122
+ month = rng.randint(1, 12)
123
+ day = rng.randint(1, 28)
124
+ year = rng.randint(2024, 2025)
125
+
126
+ return f"{hour}:{minute:02d} {am_pm}\n{month}/{day}/{year}"
127
+
128
+ @classmethod
129
+ def _place_icons(
130
+ cls,
131
+ rng: Random,
132
+ icon_config: Any,
133
+ icon_spec: IconSpec,
134
+ left_margin: int,
135
+ y_offset: int,
136
+ gap: int,
137
+ ) -> list[IconPlacement]:
138
+ """Place icons based on annotation config settings."""
139
+ placements: list[IconPlacement] = []
140
+
141
+ # Get settings from annotation config
142
+ vary_n = getattr(icon_config, "vary_n", False)
143
+ random_order = getattr(icon_config, "random_order", False)
144
+ icons = getattr(icon_config, "icons", [])
145
+
146
+ if not icons:
147
+ return placements
148
+
149
+ # Build icon list based on varyN setting
150
+ required = [i for i in icons if getattr(i, "required", False)]
151
+ optional = [i for i in icons if not getattr(i, "required", False)]
152
+
153
+ if vary_n and optional:
154
+ min_optional = max(1, int(len(optional) * 0.4))
155
+ max_optional = len(optional)
156
+ k = rng.randint(min_optional, max_optional)
157
+ selected_optional = rng.sample(optional, k)
158
+ selected = required + selected_optional
159
+ else:
160
+ selected = required + optional
161
+
162
+ # Shuffle if randomOrder is enabled
163
+ if random_order:
164
+ rng.shuffle(selected)
165
+
166
+ # Place icons left to right
167
+ x = left_margin
168
+ for icon_data in selected:
169
+ icon_id = getattr(icon_data, "icon_file_id", "") or getattr(
170
+ icon_data, "label", ""
171
+ )
172
+ label = getattr(icon_data, "label", "")
173
+
174
+ placements.append(
175
+ IconPlacement(
176
+ icon_id=icon_id,
177
+ x=x,
178
+ y=y_offset,
179
+ spec=icon_spec,
180
+ label=label,
181
+ )
182
+ )
183
+ x += icon_spec.width + gap
184
+
185
+ return placements
186
+
187
+ def get_icon_by_id(self, icon_id: str) -> IconPlacement | None:
188
+ """Find icon by ID."""
189
+ for icon in self.icons:
190
+ if icon.icon_id == icon_id:
191
+ return icon
192
+ return None
193
+
194
+ def to_ground_truth(self) -> dict[str, Any]:
195
+ """Export state as ground truth dict."""
196
+ return {
197
+ "icons": [
198
+ {
199
+ "id": icon.icon_id,
200
+ "label": icon.label,
201
+ "bounds": icon.bounds,
202
+ "center": icon.center,
203
+ }
204
+ for icon in self.icons
205
+ ],
206
+ "datetime": {
207
+ "text": self.datetime_text,
208
+ "position": self.datetime_position,
209
+ },
210
+ }
211
+
212
+
213
+ class TaskbarRenderer:
214
+ """Renderer for Windows-style taskbar.
215
+
216
+ Composites taskbar icons and datetime onto existing images.
217
+ Designed to be used as a mixin or called directly.
218
+ """
219
+
220
+ def __init__(
221
+ self,
222
+ assets_dir: Path | str = "assets",
223
+ datetime_font_size: int = 9,
224
+ ):
225
+ """Initialize the taskbar renderer.
226
+
227
+ Args:
228
+ assets_dir: Path to assets directory containing icons/taskbar/
229
+ datetime_font_size: Font size for datetime text
230
+ """
231
+ self.assets_dir = Path(assets_dir)
232
+ self.datetime_font_size = datetime_font_size
233
+ self._icon_cache: dict[str, Image.Image] = {}
234
+ self._datetime_font: ImageFont.FreeTypeFont | ImageFont.ImageFont | None = None
235
+ self._loaded = False
236
+
237
+ def load_assets(self) -> None:
238
+ """Load fonts and icon images."""
239
+ if self._loaded:
240
+ return
241
+
242
+ # Load datetime font
243
+ font_path = self.assets_dir / "fonts" / "segoeui.ttf"
244
+ if font_path.exists():
245
+ self._datetime_font = ImageFont.truetype(
246
+ str(font_path), self.datetime_font_size
247
+ )
248
+ else:
249
+ self._datetime_font = ImageFont.load_default()
250
+
251
+ # Load taskbar icons
252
+ icons_dir = self.assets_dir / "icons" / "taskbar"
253
+ if icons_dir.exists():
254
+ for icon_path in icons_dir.glob("*.png"):
255
+ icon_id = self._extract_icon_id(icon_path.stem)
256
+ self._icon_cache[icon_id] = Image.open(icon_path).convert("RGBA")
257
+
258
+ self._loaded = True
259
+
260
+ def _extract_icon_id(self, filename: str) -> str:
261
+ """Extract icon ID from filename.
262
+
263
+ Examples:
264
+ icon-tb-od -> od
265
+ icon-od-clean -> od
266
+ taskbar_m365 -> m365
267
+ taskbar_open-dental -> open-dental
268
+ """
269
+ name = filename.lower()
270
+ for prefix in ("icon-", "icon_", "tb-", "tb_", "taskbar_", "taskbar-"):
271
+ if name.startswith(prefix):
272
+ name = name[len(prefix) :]
273
+ for suffix in ("-clean", "_clean"):
274
+ if name.endswith(suffix):
275
+ name = name[: -len(suffix)]
276
+ return name
277
+
278
+ def render_onto(
279
+ self,
280
+ image: Image.Image,
281
+ state: TaskbarState,
282
+ ) -> dict[str, Any]:
283
+ """Render taskbar onto an existing image.
284
+
285
+ Args:
286
+ image: PIL Image to render onto (modified in place)
287
+ state: TaskbarState with icons and datetime
288
+
289
+ Returns:
290
+ Metadata dict with icon positions and datetime info
291
+ """
292
+ self.load_assets()
293
+
294
+ draw = ImageDraw.Draw(image)
295
+
296
+ # Draw icons (paste with alpha compositing)
297
+ for icon in state.icons:
298
+ self._draw_icon(image, icon)
299
+
300
+ # Draw datetime
301
+ self._draw_datetime(draw, state)
302
+
303
+ return state.to_ground_truth()
304
+
305
+ def _draw_icon(self, image: Image.Image, icon: IconPlacement) -> None:
306
+ """Draw a single taskbar icon."""
307
+ icon_id = icon.icon_id.lower()
308
+
309
+ # Try to find icon in cache
310
+ icon_img = self._icon_cache.get(icon_id)
311
+ if icon_img is None:
312
+ # Try aliases
313
+ aliases = {
314
+ "open_dental": "od",
315
+ "open-dental": "od",
316
+ "file_explorer": "explorer",
317
+ "microsoft_edge": "edge",
318
+ }
319
+ aliased_id = aliases.get(icon_id)
320
+ if aliased_id:
321
+ icon_img = self._icon_cache.get(aliased_id)
322
+
323
+ if icon_img is not None:
324
+ # Ensure icon has alpha channel for compositing
325
+ if icon_img.mode != "RGBA":
326
+ icon_img = icon_img.convert("RGBA")
327
+
328
+ # Use alpha channel as mask for proper compositing
329
+ if image.mode == "RGB":
330
+ # For RGB images, paste with alpha mask
331
+ image.paste(icon_img, (icon.x, icon.y), icon_img.split()[3])
332
+ else:
333
+ # For RGBA images, paste directly with alpha
334
+ image.paste(icon_img, (icon.x, icon.y), icon_img)
335
+
336
+ def _draw_datetime(self, draw: ImageDraw.ImageDraw, state: TaskbarState) -> None:
337
+ """Draw datetime text."""
338
+ if not state.datetime_text or not self._datetime_font:
339
+ return
340
+
341
+ x, y = state.datetime_position
342
+ lines = state.datetime_text.split("\n")
343
+
344
+ for i, line in enumerate(lines):
345
+ line_y = y + i * (self.datetime_font_size + 2)
346
+ # Center align text
347
+ bbox = draw.textbbox((0, 0), line, font=self._datetime_font)
348
+ text_width = bbox[2] - bbox[0]
349
+ text_x = x - text_width // 2
350
+ draw.text((text_x, line_y), line, fill="black", font=self._datetime_font)