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/__init__.py +35 -0
- html2pic/core.py +185 -0
- html2pic/css_parser.py +290 -0
- html2pic/exceptions.py +19 -0
- html2pic/html_parser.py +167 -0
- html2pic/models.py +168 -0
- html2pic/style_engine.py +442 -0
- html2pic/translator.py +944 -0
- html2pic/warnings_system.py +192 -0
- html2pic-0.1.1.dist-info/METADATA +347 -0
- html2pic-0.1.1.dist-info/RECORD +12 -0
- html2pic-0.1.1.dist-info/WHEEL +4 -0
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
|