html2pic 0.1.1__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.
html2pic/translator.py ADDED
@@ -0,0 +1,944 @@
1
+ """
2
+ DOM to PicTex translation layer - converts styled DOM tree to PicTex builders
3
+ """
4
+
5
+ from typing import Optional, Tuple, List, Union, Dict, Any
6
+ from pictex import *
7
+ from pictex import BorderStyle
8
+ from .models import DOMNode
9
+ from .exceptions import RenderError
10
+ from .warnings_system import get_warning_collector, WarningCategory
11
+
12
+ class PicTexTranslator:
13
+ """
14
+ Translates a styled DOM tree into PicTex builders.
15
+
16
+ This is the heart of the HTML->PicTex conversion. It recursively walks
17
+ the styled DOM tree and creates corresponding PicTex builders, applying
18
+ all computed styles as method calls.
19
+
20
+ The translation strategy:
21
+ 1. Determine the appropriate PicTex builder type based on HTML tag and CSS display
22
+ 2. Apply all styling properties as chained method calls
23
+ 3. Recursively process children and add them to containers
24
+ """
25
+
26
+ def __init__(self):
27
+ self.warnings = get_warning_collector()
28
+
29
+ def translate(self, styled_dom: DOMNode) -> Tuple[Canvas, Optional[Element]]:
30
+ """
31
+ Translate a styled DOM tree to PicTex builders.
32
+
33
+ Args:
34
+ styled_dom: Root DOM node with computed styles
35
+
36
+ Returns:
37
+ Tuple of (Canvas, root_element) where root_element may be None for empty docs
38
+
39
+ Raises:
40
+ RenderError: If translation fails
41
+ """
42
+ try:
43
+ # Create base canvas
44
+ canvas = Canvas()
45
+
46
+ # Process children of the root node (skip the __root__ wrapper)
47
+ if styled_dom.tag == "__root__" and styled_dom.children:
48
+ # If there's only one child, return it directly
49
+ if len(styled_dom.children) == 1:
50
+ root_element = self._translate_node(styled_dom.children[0])
51
+ else:
52
+ # Multiple children, wrap in a Column
53
+ children_elements = []
54
+ for child in styled_dom.children:
55
+ element = self._translate_node(child)
56
+ if element is not None:
57
+ children_elements.append(element)
58
+
59
+ if children_elements:
60
+ root_element = Column(*children_elements)
61
+ else:
62
+ root_element = None
63
+ else:
64
+ # Single root node
65
+ root_element = self._translate_node(styled_dom)
66
+
67
+ return canvas, root_element
68
+
69
+ except Exception as e:
70
+ raise RenderError(f"Failed to translate DOM to PicTex: {e}") from e
71
+
72
+ def _translate_node(self, node: DOMNode) -> Optional[Element]:
73
+ """
74
+ Translate a single DOM node to a PicTex builder.
75
+
76
+ Args:
77
+ node: DOM node to translate
78
+
79
+ Returns:
80
+ PicTex Element or None if node should be skipped
81
+ """
82
+ if node.is_text():
83
+ return self._create_text_element(node)
84
+ elif node.is_element():
85
+ return self._create_element_builder(node)
86
+ else:
87
+ return None
88
+
89
+ def _create_text_element(self, node: DOMNode) -> Optional[Text]:
90
+ """Create a PicTex Text element from a text node."""
91
+ text_content = node.text_content.strip()
92
+ if not text_content:
93
+ return None
94
+
95
+ # Create Text element
96
+ text_element = Text(text_content)
97
+
98
+ # Apply styles if the parent has computed styles
99
+ if node.parent and node.parent.computed_styles:
100
+ text_element = self._apply_text_styles(text_element, node.parent.computed_styles)
101
+
102
+ return text_element
103
+
104
+ def _create_element_builder(self, node: DOMNode) -> Optional[Element]:
105
+ """
106
+ Create a PicTex builder from an HTML element node.
107
+
108
+ Strategy:
109
+ 1. Determine builder type based on tag and display property
110
+ 2. Create the builder
111
+ 3. Apply styling
112
+ 4. Add children
113
+ """
114
+ styles = node.computed_styles
115
+
116
+ # Determine builder type
117
+ builder = self._determine_builder_type(node)
118
+ if builder is None:
119
+ return None
120
+
121
+ # Apply styling
122
+ builder = self._apply_element_styles(builder, styles)
123
+
124
+ # Add children for container elements
125
+ if isinstance(builder, (Row, Column)):
126
+ builder = self._add_children_to_container(builder, node)
127
+
128
+ return builder
129
+
130
+ def _determine_builder_type(self, node: DOMNode) -> Optional[Element]:
131
+ """
132
+ Determine which PicTex builder to use for an HTML element.
133
+
134
+ Decision logic:
135
+ 1. <img> -> Image
136
+ 2. Elements with flex display -> Row/Column based on flex-direction
137
+ 3. Block elements -> Column (default vertical stacking)
138
+ 4. Inline elements -> Row (horizontal flow)
139
+ 5. Text-only elements -> wrap content in Text elements
140
+ """
141
+ tag = node.tag
142
+ styles = node.computed_styles
143
+ display = styles.get('display', 'block')
144
+
145
+ # Handle img tags
146
+ if tag == 'img':
147
+ src = node.attributes.get('src')
148
+ if src:
149
+ try:
150
+ return Image(src)
151
+ except Exception as e:
152
+ self.warnings.warn_element_skipped(
153
+ f"<img src='{src}'>",
154
+ f"Failed to load image: {e}"
155
+ )
156
+ return None
157
+ else:
158
+ self.warnings.warn_element_skipped(
159
+ "<img>",
160
+ "Missing src attribute"
161
+ )
162
+ return None # Skip img without src
163
+
164
+ # Handle flex containers
165
+ if display == 'flex':
166
+ flex_direction = styles.get('flex-direction', 'row')
167
+ if flex_direction == 'column':
168
+ return Column()
169
+ else:
170
+ return Row()
171
+
172
+ # Handle text content - if element only contains text, make it a Text element
173
+ if not node.children or all(child.is_text() for child in node.children):
174
+ text_content = node.get_all_text().strip()
175
+ if text_content:
176
+ return Text(text_content)
177
+ else:
178
+ return Column()
179
+
180
+ # Default container logic based on common HTML semantics
181
+ block_tags = {'div', 'section', 'article', 'main', 'header', 'footer', 'aside', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
182
+ inline_tags = {'span', 'a', 'strong', 'em', 'b', 'i'}
183
+
184
+ if tag in block_tags or display == 'block':
185
+ return Column() # Vertical stacking for block elements
186
+ elif tag in inline_tags or display == 'inline':
187
+ return Row() # Horizontal flow for inline elements
188
+ else:
189
+ # Unknown element, default to Column
190
+ return Column()
191
+
192
+ def _apply_element_styles(self, builder: Element, styles: Dict[str, Any]) -> Element:
193
+ """Apply computed CSS styles to a PicTex builder."""
194
+
195
+ # Box model - size
196
+ width = styles.get('width', 'auto')
197
+ height = styles.get('height', 'auto')
198
+ if width != 'auto' or height != 'auto':
199
+ builder = self._apply_size(builder, width, height)
200
+
201
+ # Box model - padding
202
+ padding = self._get_box_values(styles, 'padding')
203
+ if any(float(p.rstrip('px')) > 0 for p in padding if p.endswith('px')):
204
+ if all(p == padding[0] for p in padding):
205
+ # All sides equal
206
+ builder = builder.padding(float(padding[0].rstrip('px')))
207
+ elif padding[0] == padding[2] and padding[1] == padding[3]:
208
+ # Vertical and horizontal
209
+ builder = builder.padding(
210
+ float(padding[0].rstrip('px')),
211
+ float(padding[1].rstrip('px'))
212
+ )
213
+ else:
214
+ # All four sides
215
+ builder = builder.padding(
216
+ float(padding[0].rstrip('px')),
217
+ float(padding[1].rstrip('px')),
218
+ float(padding[2].rstrip('px')),
219
+ float(padding[3].rstrip('px'))
220
+ )
221
+
222
+ # Box model - margin
223
+ margin = self._get_box_values(styles, 'margin')
224
+ if any(float(m.rstrip('px')) > 0 for m in margin if m.endswith('px')):
225
+ if all(m == margin[0] for m in margin):
226
+ builder = builder.margin(float(margin[0].rstrip('px')))
227
+ elif margin[0] == margin[2] and margin[1] == margin[3]:
228
+ builder = builder.margin(
229
+ float(margin[0].rstrip('px')),
230
+ float(margin[1].rstrip('px'))
231
+ )
232
+ else:
233
+ builder = builder.margin(
234
+ float(margin[0].rstrip('px')),
235
+ float(margin[1].rstrip('px')),
236
+ float(margin[2].rstrip('px')),
237
+ float(margin[3].rstrip('px'))
238
+ )
239
+
240
+ # Background color
241
+ bg_color = styles.get('background-color', 'transparent')
242
+ if bg_color != 'transparent':
243
+ builder = builder.background_color(bg_color)
244
+
245
+ # Background image
246
+ bg_image = styles.get('background-image', 'none')
247
+ if bg_image != 'none' and bg_image:
248
+ bg_size = styles.get('background-size', 'cover')
249
+ builder = self._apply_background_image(builder, bg_image, bg_size)
250
+
251
+ # Border
252
+ border_width = styles.get('border-width', '0px')
253
+ if border_width.endswith('px') and float(border_width[:-2]) > 0:
254
+ border_color = styles.get('border-color', 'black')
255
+ border_style = styles.get('border-style', 'solid')
256
+
257
+ # Convert CSS border style to PicTex BorderStyle
258
+ if border_style == 'dashed':
259
+ pictex_style = BorderStyle.DASHED
260
+ elif border_style == 'dotted':
261
+ pictex_style = BorderStyle.DOTTED
262
+ else:
263
+ pictex_style = BorderStyle.SOLID
264
+
265
+ builder = builder.border(
266
+ float(border_width[:-2]),
267
+ border_color,
268
+ pictex_style
269
+ )
270
+
271
+ # Border radius
272
+ border_radius = styles.get('border-radius', '0px')
273
+ if border_radius.endswith('px') and float(border_radius[:-2]) > 0:
274
+ builder = builder.border_radius(float(border_radius[:-2]))
275
+ elif border_radius.endswith('%'):
276
+ builder = builder.border_radius(border_radius)
277
+
278
+ # Box shadows
279
+ box_shadow = styles.get('box-shadow', 'none')
280
+ if box_shadow != 'none':
281
+ shadows = self._parse_box_shadows(box_shadow)
282
+ if shadows:
283
+ builder = builder.box_shadows(*shadows)
284
+
285
+ # Layout properties for containers
286
+ if isinstance(builder, (Row, Column)):
287
+ builder = self._apply_layout_styles(builder, styles)
288
+
289
+ # Typography (for Text elements or containers that might contain text)
290
+ builder = self._apply_text_styles(builder, styles)
291
+
292
+ # Positioning (absolute only)
293
+ builder = self._apply_positioning(builder, styles)
294
+
295
+ return builder
296
+
297
+ def _apply_size(self, builder: Element, width: str, height: str) -> Element:
298
+ """Apply width and height to a builder."""
299
+ width_value = None
300
+ height_value = None
301
+
302
+ # Convert width
303
+ if width != 'auto':
304
+ if width.endswith('px'):
305
+ width_value = float(width[:-2])
306
+ elif width.endswith('%'):
307
+ width_value = width
308
+ elif width in ['fit-content', 'fill-available', 'fit-background-image']:
309
+ width_value = width
310
+
311
+ # Convert height
312
+ if height != 'auto':
313
+ if height.endswith('px'):
314
+ height_value = float(height[:-2])
315
+ elif height.endswith('%'):
316
+ height_value = height
317
+ elif height in ['fit-content', 'fill-available', 'fit-background-image']:
318
+ height_value = height
319
+
320
+ if width_value is not None or height_value is not None:
321
+ return builder.size(width_value, height_value)
322
+
323
+ return builder
324
+
325
+ def _apply_layout_styles(self, builder: Union[Row, Column], styles: Dict[str, Any]) -> Union[Row, Column]:
326
+ """Apply flexbox-like layout styles to Row/Column containers."""
327
+
328
+ # Gap
329
+ gap = styles.get('gap', '0px')
330
+ if gap.endswith('px') and float(gap[:-2]) > 0:
331
+ builder = builder.gap(float(gap[:-2]))
332
+
333
+ # Flex properties
334
+ if isinstance(builder, Row):
335
+ # Horizontal distribution (main axis for Row)
336
+ justify_content = styles.get('justify-content', 'flex-start')
337
+ builder = self._apply_distribution(builder, justify_content, 'horizontal')
338
+
339
+ # Vertical alignment (cross axis for Row)
340
+ align_items = styles.get('align-items', 'stretch')
341
+ builder = self._apply_alignment(builder, align_items, 'vertical')
342
+
343
+ elif isinstance(builder, Column):
344
+ # Vertical distribution (main axis for Column)
345
+ justify_content = styles.get('justify-content', 'flex-start')
346
+ builder = self._apply_distribution(builder, justify_content, 'vertical')
347
+
348
+ # Horizontal alignment (cross axis for Column)
349
+ align_items = styles.get('align-items', 'stretch')
350
+ builder = self._apply_alignment(builder, align_items, 'horizontal')
351
+
352
+ return builder
353
+
354
+ def _apply_distribution(self, builder: Union[Row, Column], justify_value: str, axis: str) -> Union[Row, Column]:
355
+ """Apply justify-content CSS property to PicTex distribution."""
356
+ # Map CSS values to PicTex values
357
+ distribution_map = {
358
+ 'flex-start': 'left' if axis == 'horizontal' else 'top',
359
+ 'center': 'center',
360
+ 'flex-end': 'right' if axis == 'horizontal' else 'bottom',
361
+ 'space-between': 'space-between',
362
+ 'space-around': 'space-around',
363
+ 'space-evenly': 'space-evenly'
364
+ }
365
+
366
+ pictex_value = distribution_map.get(justify_value, 'left' if axis == 'horizontal' else 'top')
367
+
368
+ if axis == 'horizontal' and isinstance(builder, Row):
369
+ return builder.horizontal_distribution(pictex_value)
370
+ elif axis == 'vertical' and isinstance(builder, Column):
371
+ return builder.vertical_distribution(pictex_value)
372
+
373
+ return builder
374
+
375
+ def _apply_alignment(self, builder: Union[Row, Column], align_value: str, axis: str) -> Union[Row, Column]:
376
+ """Apply align-items CSS property to PicTex alignment."""
377
+ # Map CSS values to PicTex values
378
+ alignment_map = {
379
+ 'flex-start': 'left' if axis == 'horizontal' else 'top',
380
+ 'center': 'center',
381
+ 'flex-end': 'right' if axis == 'horizontal' else 'bottom',
382
+ 'stretch': 'stretch'
383
+ }
384
+
385
+ pictex_value = alignment_map.get(align_value, 'stretch')
386
+
387
+ if axis == 'vertical' and isinstance(builder, Row):
388
+ return builder.vertical_align(pictex_value)
389
+ elif axis == 'horizontal' and isinstance(builder, Column):
390
+ return builder.horizontal_align(pictex_value)
391
+
392
+ return builder
393
+
394
+ def _apply_text_styles(self, builder: Element, styles: Dict[str, Any]) -> Element:
395
+ """Apply typography styles to a builder."""
396
+
397
+ # Font family
398
+ font_family = styles.get('font-family', '')
399
+ if font_family and font_family != 'Arial, sans-serif': # Skip default
400
+ # Take first font from font stack
401
+ first_font = font_family.split(',')[0].strip().strip('"\'')
402
+ builder = builder.font_family(first_font)
403
+
404
+ # Font size
405
+ font_size = styles.get('font-size', '16px')
406
+ if font_size.endswith('px'):
407
+ builder = builder.font_size(float(font_size[:-2]))
408
+
409
+ # Font weight
410
+ font_weight = styles.get('font-weight', '400')
411
+ if font_weight.isdigit():
412
+ weight_num = int(font_weight)
413
+ builder = builder.font_weight(weight_num)
414
+ elif font_weight in ['bold', 'bolder']:
415
+ builder = builder.font_weight(FontWeight.BOLD)
416
+
417
+ # Font style
418
+ font_style = styles.get('font-style', 'normal')
419
+ if font_style == 'italic':
420
+ builder = builder.font_style(FontStyle.ITALIC)
421
+
422
+ # Text color
423
+ color = styles.get('color', 'black')
424
+ builder = builder.color(color)
425
+
426
+ # Text align
427
+ text_align = styles.get('text-align', 'left')
428
+ if text_align == 'center':
429
+ builder = builder.text_align(TextAlign.CENTER)
430
+ elif text_align == 'right':
431
+ builder = builder.text_align(TextAlign.RIGHT)
432
+
433
+ # Line height
434
+ line_height = styles.get('line-height', '1.2')
435
+ try:
436
+ lh_value = float(line_height)
437
+ builder = builder.line_height(lh_value)
438
+ except ValueError:
439
+ pass # Skip invalid line-height values
440
+
441
+ # Text shadows
442
+ text_shadow = styles.get('text-shadow', 'none')
443
+ if text_shadow != 'none':
444
+ shadows = self._parse_text_shadows(text_shadow)
445
+ if shadows:
446
+ builder = builder.text_shadows(*shadows)
447
+
448
+ return builder
449
+
450
+ def _apply_positioning(self, builder: Element, styles: Dict[str, Any]) -> Element:
451
+ """Apply CSS positioning (absolute only) to a builder."""
452
+ position = styles.get('position', 'static')
453
+
454
+ if position == 'absolute':
455
+ left = styles.get('left', 'auto')
456
+ top = styles.get('top', 'auto')
457
+
458
+ # Only apply positioning if left or top are specified
459
+ if left != 'auto' or top != 'auto':
460
+ x_pos = self._parse_position_value(left) if left != 'auto' else 0
461
+ y_pos = self._parse_position_value(top) if top != 'auto' else 0
462
+
463
+ # Use PicTex's absolute_position (which is actually relative to root canvas)
464
+ builder = builder.absolute_position(x_pos, y_pos)
465
+ elif position == 'relative':
466
+ # Warn that relative positioning is not supported
467
+ if any(styles.get(prop, 'auto') != 'auto' for prop in ['left', 'top', 'right', 'bottom']):
468
+ self.warnings.warn_style_not_applied(
469
+ 'position', 'relative', 'element',
470
+ 'Relative positioning is not supported. Use absolute positioning with left/top instead.'
471
+ )
472
+ elif position != 'static' and position != 'auto':
473
+ # Warn about other unsupported position values
474
+ self.warnings.warn_style_not_applied(
475
+ 'position', position, 'element',
476
+ f"Position '{position}' is not supported. Only 'absolute' is supported."
477
+ )
478
+
479
+ return builder
480
+
481
+ def _parse_position_value(self, value: str) -> Union[float, str]:
482
+ """Parse CSS position value (left, top, etc.) to PicTex format."""
483
+ if value == 'auto':
484
+ return 0
485
+
486
+ # Handle pixel values
487
+ if value.endswith('px'):
488
+ return float(value[:-2])
489
+
490
+ # Handle percentage values
491
+ if value.endswith('%'):
492
+ return value # PicTex supports percentage strings
493
+
494
+ # Handle em/rem values (approximate)
495
+ if value.endswith('em'):
496
+ return float(value[:-2]) * 16
497
+ if value.endswith('rem'):
498
+ return float(value[:-3]) * 16
499
+
500
+ # Try to parse as number
501
+ try:
502
+ return float(value)
503
+ except ValueError:
504
+ return 0
505
+
506
+ def _add_children_to_container(self, container: Union[Row, Column], node: DOMNode) -> Union[Row, Column]:
507
+ """Add child elements to a Row or Column container."""
508
+ child_elements = []
509
+
510
+ for child in node.children:
511
+ child_element = self._translate_node(child)
512
+ if child_element is not None:
513
+ child_elements.append(child_element)
514
+
515
+ # Create new container with children
516
+ if isinstance(container, Row):
517
+ new_container = Row(*child_elements)
518
+ else: # Column
519
+ new_container = Column(*child_elements)
520
+
521
+ # Copy over the styling from the original container
522
+ new_container._style = container._style
523
+
524
+ return new_container
525
+
526
+ def _get_box_values(self, styles: Dict[str, Any], property_prefix: str) -> List[str]:
527
+ """Get box model values (padding/margin) in [top, right, bottom, left] order."""
528
+ top = styles.get(f'{property_prefix}-top', '0px')
529
+ right = styles.get(f'{property_prefix}-right', '0px')
530
+ bottom = styles.get(f'{property_prefix}-bottom', '0px')
531
+ left = styles.get(f'{property_prefix}-left', '0px')
532
+
533
+ return [top, right, bottom, left]
534
+
535
+ def _parse_box_shadows(self, box_shadow_value: str) -> List[Shadow]:
536
+ """
537
+ Parse CSS box-shadow value into PicTex Shadow objects.
538
+
539
+ Supports: offset-x offset-y blur-radius color
540
+ Example: "2px 2px 4px rgba(0,0,0,0.5)"
541
+ """
542
+ shadows = []
543
+
544
+ # Split multiple shadows by comma
545
+ shadow_parts = box_shadow_value.split(',')
546
+
547
+ for shadow_part in shadow_parts:
548
+ shadow_part = shadow_part.strip()
549
+ if shadow_part == 'none':
550
+ continue
551
+
552
+ try:
553
+ shadow = self._parse_single_shadow(shadow_part)
554
+ if shadow:
555
+ shadows.append(shadow)
556
+ except Exception as e:
557
+ self.warnings.warn_style_not_applied(
558
+ 'box-shadow', shadow_part, 'element', f'Failed to parse shadow: {e}'
559
+ )
560
+
561
+ return shadows
562
+
563
+ def _parse_text_shadows(self, text_shadow_value: str) -> List[Shadow]:
564
+ """
565
+ Parse CSS text-shadow value into PicTex Shadow objects.
566
+
567
+ Same format as box-shadow: offset-x offset-y blur-radius color
568
+ """
569
+ return self._parse_box_shadows(text_shadow_value) # Same parsing logic
570
+
571
+ def _parse_single_shadow(self, shadow_str: str) -> Optional[Shadow]:
572
+ """
573
+ Parse a single shadow string into a Shadow object.
574
+
575
+ Format: "offset-x offset-y blur-radius color"
576
+ Example: "2px 2px 4px rgba(0,0,0,0.5)"
577
+ """
578
+ shadow_str = shadow_str.strip()
579
+
580
+ # Smart parsing that doesn't break RGBA colors
581
+ parts = []
582
+ current_part = ""
583
+ in_parentheses = 0
584
+
585
+ for char in shadow_str + " ": # Add space at end to trigger last part
586
+ if char == "(":
587
+ in_parentheses += 1
588
+ current_part += char
589
+ elif char == ")":
590
+ in_parentheses -= 1
591
+ current_part += char
592
+ elif char.isspace() and in_parentheses == 0:
593
+ if current_part.strip():
594
+ parts.append(current_part.strip())
595
+ current_part = ""
596
+ else:
597
+ current_part += char
598
+
599
+ if len(parts) < 3:
600
+ return None
601
+
602
+ try:
603
+ # Parse offset values
604
+ offset_x = self._parse_length_value(parts[0])
605
+ offset_y = self._parse_length_value(parts[1])
606
+
607
+ # Parse blur radius
608
+ blur_radius = self._parse_length_value(parts[2])
609
+
610
+ # Parse color (remaining parts)
611
+ if len(parts) > 3:
612
+ color_str = ' '.join(parts[3:])
613
+ else:
614
+ color_str = 'rgba(0,0,0,0.5)' # Default shadow color
615
+
616
+ # Convert color to SolidColor format
617
+ if color_str.startswith('rgba(') or color_str.startswith('rgb('):
618
+ # Parse RGBA to get proper color
619
+ from .style_engine import StyleEngine
620
+ style_engine = StyleEngine()
621
+ normalized_color = style_engine._normalize_color(color_str)
622
+ if normalized_color == 'transparent':
623
+ return None # Skip transparent shadows
624
+ color_str = normalized_color
625
+
626
+ # Create PicTex Shadow object
627
+ return Shadow(
628
+ offset=(offset_x, offset_y),
629
+ blur_radius=blur_radius,
630
+ color=color_str
631
+ )
632
+
633
+ except Exception as e:
634
+ return None
635
+
636
+ def _parse_length_value(self, value_str: str) -> float:
637
+ """Parse a CSS length value to pixels."""
638
+ value_str = value_str.strip()
639
+
640
+ if value_str.endswith('px'):
641
+ return float(value_str[:-2])
642
+ elif value_str.endswith('em'):
643
+ # Approximate conversion (em to px)
644
+ return float(value_str[:-2]) * 16
645
+ elif value_str.endswith('rem'):
646
+ return float(value_str[:-3]) * 16
647
+ else:
648
+ # Try to parse as number (assume pixels)
649
+ return float(value_str)
650
+
651
+ def _apply_background_image(self, builder: Element, bg_image: str, bg_size: str) -> Element:
652
+ """
653
+ Apply CSS background-image to a PicTex builder.
654
+
655
+ Args:
656
+ builder: PicTex element builder
657
+ bg_image: CSS background-image value (url(...) or none)
658
+ bg_size: CSS background-size value (cover, contain, or specific size)
659
+
660
+ Returns:
661
+ Builder with background image applied
662
+ """
663
+ try:
664
+ # Check if it's a linear gradient
665
+ if bg_image.startswith('linear-gradient('):
666
+ # Parse CSS linear-gradient and convert to PicTex LinearGradient
667
+ linear_gradient = self._parse_linear_gradient(bg_image)
668
+ if linear_gradient:
669
+ # Use background_color() with gradient (PicTex accepts gradients here)
670
+ builder = builder.background_color(linear_gradient)
671
+ return builder
672
+ else:
673
+ # Parse CSS background-image url() syntax
674
+ image_path = self._parse_background_image_url(bg_image)
675
+ if not image_path:
676
+ return builder
677
+
678
+ # Map CSS background-size to PicTex size_mode
679
+ size_mode = self._map_background_size(bg_size)
680
+
681
+ # Apply background image
682
+ builder = builder.background_image(image_path, size_mode=size_mode)
683
+
684
+ return builder
685
+
686
+ except Exception as e:
687
+ self.warnings.warn_style_not_applied(
688
+ 'background-image', bg_image, 'element',
689
+ f'Failed to apply background image: {e}'
690
+ )
691
+ return builder
692
+
693
+ def _parse_background_image_url(self, bg_image: str) -> Optional[str]:
694
+ """
695
+ Parse CSS background-image url() value to extract the image path.
696
+
697
+ Examples:
698
+ - url("image.png") -> "image.png"
699
+ - url('image.jpg') -> "image.jpg"
700
+ - url(image.gif) -> "image.gif"
701
+ - linear-gradient(...) -> None (not supported)
702
+ """
703
+ bg_image = bg_image.strip()
704
+
705
+ # Check for url() function
706
+ if bg_image.startswith('url(') and bg_image.endswith(')'):
707
+ url_content = bg_image[4:-1].strip() # Remove 'url(' and ')'
708
+
709
+ # Remove quotes if present
710
+ if (url_content.startswith('"') and url_content.endswith('"')) or \
711
+ (url_content.startswith("'") and url_content.endswith("'")):
712
+ url_content = url_content[1:-1]
713
+
714
+ return url_content
715
+
716
+ # Check for linear-gradient (now supported!)
717
+ elif bg_image.startswith('linear-gradient('):
718
+ return bg_image # Return the gradient string for processing
719
+
720
+ # Check for other unsupported gradient types
721
+ elif bg_image.startswith(('radial-gradient', 'conic-gradient')):
722
+ self.warnings.warn_style_not_applied(
723
+ 'background-image', bg_image, 'element',
724
+ 'Only linear-gradient is supported. Radial and conic gradients are not yet implemented.'
725
+ )
726
+ return None
727
+
728
+ return None
729
+
730
+ def _map_background_size(self, bg_size: str) -> str:
731
+ """
732
+ Map CSS background-size to PicTex size_mode.
733
+
734
+ CSS background-size values:
735
+ - cover: Scale image to cover entire container (may crop)
736
+ - contain: Scale image to fit inside container (may leave empty space)
737
+ - auto: Use image's natural size (similar to tile)
738
+
739
+ PicTex size_mode values:
740
+ - cover: Scale to cover
741
+ - contain: Scale to fit
742
+ - tile: Repeat at natural size
743
+ """
744
+ bg_size = bg_size.strip().lower()
745
+
746
+ if bg_size == 'cover':
747
+ return 'cover'
748
+ elif bg_size == 'contain':
749
+ return 'contain'
750
+ elif bg_size in ['auto', 'initial']:
751
+ return 'tile' # Use tile for natural size
752
+ else:
753
+ # For specific sizes like "100px 200px", we don't support it yet
754
+ # Fall back to cover as a reasonable default
755
+ if bg_size not in ['cover', 'contain', 'tile']:
756
+ self.warnings.warn_style_not_applied(
757
+ 'background-size', bg_size, 'element',
758
+ f'Specific background-size dimensions not supported. Using "cover" instead. Supported values: cover, contain, auto'
759
+ )
760
+ return 'cover'
761
+
762
+ def _parse_linear_gradient(self, gradient_str: str) -> Optional[LinearGradient]:
763
+ """
764
+ Parse CSS linear-gradient() syntax and convert to PicTex LinearGradient.
765
+
766
+ Supports:
767
+ - linear-gradient(135deg, #667eea 0%, #764ba2 100%)
768
+ - linear-gradient(to right, red, blue)
769
+ - linear-gradient(45deg, red, yellow, blue)
770
+
771
+ Args:
772
+ gradient_str: CSS linear-gradient string
773
+
774
+ Returns:
775
+ LinearGradient object or None if parsing fails
776
+ """
777
+ try:
778
+ # Remove 'linear-gradient(' and ')'
779
+ if not gradient_str.startswith('linear-gradient(') or not gradient_str.endswith(')'):
780
+ return None
781
+
782
+ content = gradient_str[16:-1].strip() # Remove 'linear-gradient(' and ')'
783
+
784
+ # Split by comma, but be careful with nested parentheses (rgba colors)
785
+ parts = self._smart_split_gradient(content)
786
+
787
+ if not parts:
788
+ return None
789
+
790
+ # Parse direction (first part if it's a direction)
791
+ direction = parts[0].strip()
792
+ colors_start_index = 0
793
+
794
+ start_point, end_point = self._parse_gradient_direction(direction)
795
+ if start_point is not None:
796
+ # First part was a direction, colors start from second part
797
+ colors_start_index = 1
798
+ else:
799
+ # First part is a color, use default direction (left to right)
800
+ start_point = (0.0, 0.0)
801
+ end_point = (1.0, 0.0)
802
+
803
+ # Parse colors and stops
804
+ color_parts = parts[colors_start_index:]
805
+ if len(color_parts) < 2:
806
+ return None # Need at least 2 colors
807
+
808
+ colors = []
809
+ stops = []
810
+
811
+ for i, part in enumerate(color_parts):
812
+ color, stop = self._parse_gradient_color_stop(part.strip())
813
+ if color:
814
+ colors.append(color)
815
+ if stop is not None:
816
+ stops.append(stop)
817
+ else:
818
+ # Auto-distribute stops if not specified
819
+ if len(color_parts) == 2:
820
+ stops.append(0.0 if i == 0 else 1.0)
821
+ else:
822
+ stops.append(i / (len(color_parts) - 1))
823
+
824
+ if len(colors) < 2:
825
+ return None
826
+
827
+ # Create PicTex LinearGradient
828
+ return LinearGradient(
829
+ colors=colors,
830
+ stops=stops if len(stops) == len(colors) else None,
831
+ start_point=start_point,
832
+ end_point=end_point
833
+ )
834
+
835
+ except Exception as e:
836
+ self.warnings.warn_style_not_applied(
837
+ 'background-image', gradient_str, 'element',
838
+ f'Failed to parse linear-gradient: {e}'
839
+ )
840
+ return None
841
+
842
+ def _smart_split_gradient(self, content: str) -> List[str]:
843
+ """Split gradient content by comma, respecting parentheses"""
844
+ parts = []
845
+ current_part = ""
846
+ paren_depth = 0
847
+
848
+ for char in content:
849
+ if char == '(':
850
+ paren_depth += 1
851
+ elif char == ')':
852
+ paren_depth -= 1
853
+ elif char == ',' and paren_depth == 0:
854
+ if current_part.strip():
855
+ parts.append(current_part.strip())
856
+ current_part = ""
857
+ continue
858
+ current_part += char
859
+
860
+ if current_part.strip():
861
+ parts.append(current_part.strip())
862
+
863
+ return parts
864
+
865
+ def _parse_gradient_direction(self, direction: str) -> Tuple[Optional[Tuple[float, float]], Optional[Tuple[float, float]]]:
866
+ """
867
+ Parse CSS gradient direction and convert to start/end points.
868
+
869
+ Returns (start_point, end_point) or (None, None) if not a direction
870
+ """
871
+ direction = direction.lower().strip()
872
+
873
+ # Angle directions (e.g., 45deg, 135deg)
874
+ if direction.endswith('deg'):
875
+ try:
876
+ angle = float(direction[:-3])
877
+ # Convert angle to start/end points
878
+ # CSS angles: 0deg = to top, 90deg = to right, 180deg = to bottom, 270deg = to left
879
+ # We need to convert to start/end coordinates
880
+ return self._angle_to_points(angle)
881
+ except ValueError:
882
+ return None, None
883
+
884
+ # Keyword directions
885
+ direction_map = {
886
+ 'to right': ((0.0, 0.0), (1.0, 0.0)),
887
+ 'to left': ((1.0, 0.0), (0.0, 0.0)),
888
+ 'to bottom': ((0.0, 0.0), (0.0, 1.0)),
889
+ 'to top': ((0.0, 1.0), (0.0, 0.0)),
890
+ 'to bottom right': ((0.0, 0.0), (1.0, 1.0)),
891
+ 'to bottom left': ((1.0, 0.0), (0.0, 1.0)),
892
+ 'to top right': ((0.0, 1.0), (1.0, 0.0)),
893
+ 'to top left': ((1.0, 1.0), (0.0, 0.0)),
894
+ }
895
+
896
+ if direction in direction_map:
897
+ return direction_map[direction]
898
+
899
+ return None, None
900
+
901
+ def _angle_to_points(self, angle: float) -> Tuple[Tuple[float, float], Tuple[float, float]]:
902
+ """Convert CSS angle to start/end points"""
903
+ import math
904
+
905
+ # Normalize angle to 0-360 range
906
+ angle = angle % 360
907
+
908
+ # CSS gradient angles: 0deg = up, 90deg = right, 180deg = down, 270deg = left
909
+ # Convert to radians and adjust for coordinate system
910
+ rad = math.radians(angle - 90) # -90 to align with CSS convention
911
+
912
+ # Calculate end point on unit circle
913
+ end_x = (math.cos(rad) + 1) / 2 # Convert from [-1,1] to [0,1]
914
+ end_y = (math.sin(rad) + 1) / 2
915
+
916
+ # Start point is opposite
917
+ start_x = 1 - end_x
918
+ start_y = 1 - end_y
919
+
920
+ return (start_x, start_y), (end_x, end_y)
921
+
922
+ def _parse_gradient_color_stop(self, part: str) -> Tuple[Optional[str], Optional[float]]:
923
+ """
924
+ Parse a color stop like 'red 50%' or '#ff0000' or 'rgba(255,0,0,0.5) 25%'
925
+
926
+ Returns (color, stop_position) where stop_position is 0.0-1.0 or None
927
+ """
928
+ part = part.strip()
929
+
930
+ # Check if it has a percentage at the end
931
+ if part.endswith('%'):
932
+ # Find the last space before the percentage
933
+ space_index = part.rfind(' ')
934
+ if space_index > 0:
935
+ color_part = part[:space_index].strip()
936
+ percent_part = part[space_index + 1:].strip()
937
+ try:
938
+ percent = float(percent_part[:-1]) # Remove '%'
939
+ return color_part, percent / 100.0
940
+ except ValueError:
941
+ pass
942
+
943
+ # No percentage, just return the color
944
+ return part, None