stackraise 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stackraise/__init__.py +6 -0
- stackraise/ai/__init__.py +2 -0
- stackraise/ai/rpa.py +380 -0
- stackraise/ai/toolset.py +227 -0
- stackraise/app.py +23 -0
- stackraise/auth/__init__.py +2 -0
- stackraise/auth/model.py +24 -0
- stackraise/auth/service.py +240 -0
- stackraise/ctrl/__init__.py +4 -0
- stackraise/ctrl/change_stream.py +40 -0
- stackraise/ctrl/crud_controller.py +63 -0
- stackraise/ctrl/file_storage.py +68 -0
- stackraise/db/__init__.py +11 -0
- stackraise/db/adapter.py +60 -0
- stackraise/db/collection.py +292 -0
- stackraise/db/cursor.py +229 -0
- stackraise/db/document.py +282 -0
- stackraise/db/exceptions.py +9 -0
- stackraise/db/id.py +79 -0
- stackraise/db/index.py +84 -0
- stackraise/db/persistence.py +238 -0
- stackraise/db/pipeline.py +245 -0
- stackraise/db/protocols.py +141 -0
- stackraise/di.py +36 -0
- stackraise/event.py +150 -0
- stackraise/inflection.py +28 -0
- stackraise/io/__init__.py +3 -0
- stackraise/io/imap_client.py +400 -0
- stackraise/io/smtp_client.py +102 -0
- stackraise/logging.py +22 -0
- stackraise/model/__init__.py +11 -0
- stackraise/model/core.py +16 -0
- stackraise/model/dto.py +12 -0
- stackraise/model/email_message.py +88 -0
- stackraise/model/file.py +154 -0
- stackraise/model/name_email.py +45 -0
- stackraise/model/query_filters.py +231 -0
- stackraise/model/time_range.py +285 -0
- stackraise/model/validation.py +8 -0
- stackraise/templating/__init__.py +4 -0
- stackraise/templating/exceptions.py +23 -0
- stackraise/templating/image/__init__.py +2 -0
- stackraise/templating/image/model.py +51 -0
- stackraise/templating/image/processor.py +154 -0
- stackraise/templating/parser.py +156 -0
- stackraise/templating/pptx/__init__.py +3 -0
- stackraise/templating/pptx/pptx_engine.py +204 -0
- stackraise/templating/pptx/slide_renderer.py +181 -0
- stackraise/templating/tracer.py +57 -0
- stackraise-0.1.0.dist-info/METADATA +37 -0
- stackraise-0.1.0.dist-info/RECORD +52 -0
- stackraise-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# %%
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import UTC
|
|
7
|
+
from datetime import date as Date
|
|
8
|
+
from datetime import datetime as DateTime
|
|
9
|
+
from datetime import time as Time
|
|
10
|
+
from datetime import timedelta as TimeDelta
|
|
11
|
+
from datetime import timezone as TimeZone
|
|
12
|
+
from typing import Optional, TypeAlias
|
|
13
|
+
|
|
14
|
+
from pydantic.dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
TimeDeltaParseable: TypeAlias = int | str | TimeDelta
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DateTimeParseable: TypeAlias = int | str | Date | DateTime
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class TimeRange:
|
|
24
|
+
start: DateTime
|
|
25
|
+
stop: DateTime
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_for_an_exact_day(self) -> Optional[Date]:
|
|
29
|
+
if _datetime_is_day_exact(self.start) and self.stop == _datetime_next_day(
|
|
30
|
+
self.start
|
|
31
|
+
):
|
|
32
|
+
return self.start.date()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_for_exact_days(self) -> bool:
|
|
36
|
+
return _datetime_is_day_exact(self.start) and _datetime_is_day_exact(self.stop)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_for_an_exact_month(self) -> bool:
|
|
40
|
+
return _datetime_is_month_exact(self.start) and self.stop == _datetime_next_month(
|
|
41
|
+
self.start
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_for_exact_months(self) -> bool:
|
|
46
|
+
return _datetime_is_month_exact(self.start) and _datetime_is_month_exact(
|
|
47
|
+
self.stop
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_for_an_exact_year(self) -> bool:
|
|
52
|
+
return _datetime_is_year_exact(self.start) and self.stop == _datetime_next_year(
|
|
53
|
+
self.start
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_for_exact_years(self) -> bool:
|
|
58
|
+
return _datetime_is_year_exact(self.start) and _datetime_is_year_exact(self.stop)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def extent(self) -> TimeDelta:
|
|
62
|
+
return self.stop - self.start
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def of(cls, s: TimeRangeParseable):
|
|
66
|
+
if isinstance(s, TimeRange):
|
|
67
|
+
return s
|
|
68
|
+
if isinstance(s, str):
|
|
69
|
+
return _parse_timerange(s)
|
|
70
|
+
|
|
71
|
+
raise ValueError(f"Invalid TimeRange string: {s}")
|
|
72
|
+
|
|
73
|
+
def __str__(self):
|
|
74
|
+
if self.is_for_an_exact_year:
|
|
75
|
+
fmt = f"{self.start.year}"
|
|
76
|
+
elif self.is_for_an_exact_month:
|
|
77
|
+
fmt = f"{self.start.year}-{self.start.month:02}"
|
|
78
|
+
elif self.is_for_an_exact_day:
|
|
79
|
+
fmt = f"{self.start.date()}"
|
|
80
|
+
else:
|
|
81
|
+
fmt = f"{_repr_datetime(self.start)}..{_repr_datetime(self.stop)}"
|
|
82
|
+
|
|
83
|
+
return fmt
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def duration(self) -> TimeDelta:
|
|
87
|
+
return self.stop - self.start
|
|
88
|
+
|
|
89
|
+
def __contains__(self, dt: DateTime):
|
|
90
|
+
return self.start <= dt < self.stop
|
|
91
|
+
|
|
92
|
+
def replace(self, **kwargs):
|
|
93
|
+
return replace(self, **kwargs)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def overlap(cls, a: TimeRange, b: TimeRange) -> Optional[TimeRange]:
|
|
97
|
+
start = max(a.start, b.start)
|
|
98
|
+
stop = min(a.stop, b.stop)
|
|
99
|
+
if start < stop:
|
|
100
|
+
return cls(start=start, stop=stop)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
TimeRangeParseable: TypeAlias = str | TimeRange
|
|
104
|
+
|
|
105
|
+
# Expresiones regulares
|
|
106
|
+
DELTA_REGEX = re.compile(
|
|
107
|
+
r"^(?:(?P<days>\d+)d)?\s?" # Días opcionales
|
|
108
|
+
r"(?:(?P<hours>\d+)h)?\s?" # Horas opcionales
|
|
109
|
+
r"(?:(?P<minutes>\d+)m)?\s?" # Minutos opcionales
|
|
110
|
+
r"(?:(?P<seconds>\d+)s)?$\s?" # Segundos opcionales
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_timedelta(s: TimeDeltaParseable) -> TimeDelta:
|
|
115
|
+
if isinstance(s, TimeDelta):
|
|
116
|
+
return s
|
|
117
|
+
if isinstance(s, int):
|
|
118
|
+
return TimeDelta(milliseconds=s)
|
|
119
|
+
|
|
120
|
+
match = DELTA_REGEX.match(s)
|
|
121
|
+
if not match:
|
|
122
|
+
raise ValueError(f"Invalid timedelta string: {s}")
|
|
123
|
+
|
|
124
|
+
return TimeDelta(
|
|
125
|
+
**{key: int(value) for key, value in match.groupdict(default="0").items()}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
DATE_REGEXES = [
|
|
130
|
+
re.compile(r"^(?P<year>\d{4})$"), # Solo año
|
|
131
|
+
re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})$"), # Año y mes
|
|
132
|
+
re.compile(r"^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$"), # Año, mes y día
|
|
133
|
+
re.compile(
|
|
134
|
+
r"^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})T(?P<hour>\d{2}):(?P<minute>\d{2})(?::(?P<second>\d{2}))?$"
|
|
135
|
+
), # Fecha y hora
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_datetime(dt: DateTimeParseable) -> DateTime:
|
|
140
|
+
if isinstance(dt, int):
|
|
141
|
+
return DateTime.fromtimestamp(dt).replace(tzinfo=TimeZone.utc)
|
|
142
|
+
elif isinstance(dt, str):
|
|
143
|
+
try:
|
|
144
|
+
for regex in DATE_REGEXES:
|
|
145
|
+
if match := regex.match(dt):
|
|
146
|
+
components = match.groupdict()
|
|
147
|
+
return DateTime(
|
|
148
|
+
year=int(components["year"]),
|
|
149
|
+
month=int(components.get("month", 1)),
|
|
150
|
+
day=int(components.get("day", 1)),
|
|
151
|
+
hour=int(components.get("hour", 0)),
|
|
152
|
+
minute=int(components.get("minute", 0)),
|
|
153
|
+
second=int(components.get("second", 0)),
|
|
154
|
+
tzinfo=UTC,
|
|
155
|
+
)
|
|
156
|
+
raise ValueError(f"Invalid date time string: {dt}")
|
|
157
|
+
|
|
158
|
+
except:
|
|
159
|
+
dt = Date.fromisoformat(dt)
|
|
160
|
+
|
|
161
|
+
if isinstance(dt, Date):
|
|
162
|
+
return DateTime.combine(dt, Time.min).replace(tzinfo=UTC)
|
|
163
|
+
elif isinstance(dt, DateTime):
|
|
164
|
+
return DateTime.fromtimestamp(dt.timestamp()).replace(tzinfo=UTC)
|
|
165
|
+
else:
|
|
166
|
+
raise ValueError(f"Invalid type {type(dt)}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
RANGE_REGEX = re.compile(
|
|
170
|
+
r"^"
|
|
171
|
+
# Rango explícito "start .. stop"
|
|
172
|
+
r"((?P<start>.+?)\s*\.\.\s*(?P<stop>.+?)(\s)?)"
|
|
173
|
+
r"$"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
RANGE_SEP = re.compile(r"[\s_]+")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_timerange(input_str: str) -> TimeRange:
|
|
180
|
+
"""
|
|
181
|
+
Parsea una cadena de entrada para determinar el rango de tiempo y el delta.
|
|
182
|
+
"""
|
|
183
|
+
# Detectar rango explícito
|
|
184
|
+
range_match = RANGE_REGEX.match(input_str)
|
|
185
|
+
if range_match:
|
|
186
|
+
start = _parse_datetime(range_match.group("start"))
|
|
187
|
+
stop = _parse_datetime(range_match.group("stop"))
|
|
188
|
+
return TimeRange(start=start, stop=stop)
|
|
189
|
+
|
|
190
|
+
# Detectar rango implícito (un solo valor)
|
|
191
|
+
start = _parse_datetime(input_str)
|
|
192
|
+
if len(input_str) == 4: # Año
|
|
193
|
+
stop = _datetime_next_year(start)
|
|
194
|
+
elif len(input_str) == 7: # Año y mes
|
|
195
|
+
stop = _datetime_next_month(start)
|
|
196
|
+
elif len(input_str) == 10: # Año, mes y día
|
|
197
|
+
stop = _datetime_next_day(start)
|
|
198
|
+
else:
|
|
199
|
+
raise ValueError(f"Invalid TimeRange string: {input_str}")
|
|
200
|
+
|
|
201
|
+
return TimeRange(start=start, stop=stop)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _repr_timedelta(time_delta: TimeDelta):
|
|
205
|
+
parts = []
|
|
206
|
+
if time_delta < TimeDelta():
|
|
207
|
+
time_delta = -time_delta
|
|
208
|
+
parts.append("-")
|
|
209
|
+
|
|
210
|
+
weeks, days = divmod(time_delta.days, 7)
|
|
211
|
+
hours, seconds = divmod(time_delta.seconds, 3600)
|
|
212
|
+
minutes, seconds = divmod(seconds, 60)
|
|
213
|
+
|
|
214
|
+
if weeks:
|
|
215
|
+
parts.append(f"{weeks}w")
|
|
216
|
+
if days:
|
|
217
|
+
parts.append(f"{days}d")
|
|
218
|
+
if hours:
|
|
219
|
+
parts.append(f"{hours}h")
|
|
220
|
+
if minutes:
|
|
221
|
+
parts.append(f"{minutes}m")
|
|
222
|
+
if seconds:
|
|
223
|
+
parts.append(f"{seconds}s")
|
|
224
|
+
|
|
225
|
+
return "".join(parts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _repr_datetime(dt: DateTime) -> str:
|
|
229
|
+
if _datetime_is_year_exact(dt):
|
|
230
|
+
return str(dt.year)
|
|
231
|
+
if _datetime_is_month_exact(dt):
|
|
232
|
+
return f"{dt.year}-{dt.month:02}"
|
|
233
|
+
if _datetime_is_day_exact(dt):
|
|
234
|
+
return str(dt.date())
|
|
235
|
+
return dt.isoformat()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _datetime_is_day_exact(dt: DateTime) -> bool:
|
|
239
|
+
return dt.time() == Time.min
|
|
240
|
+
|
|
241
|
+
def _datetime_is_month_exact(dt: DateTime) -> bool:
|
|
242
|
+
return dt.day == 1 and _datetime_is_day_exact(dt)
|
|
243
|
+
|
|
244
|
+
def _datetime_is_year_exact(dt: DateTime) -> bool:
|
|
245
|
+
return dt.month == 1 and _datetime_is_month_exact(dt)
|
|
246
|
+
|
|
247
|
+
def _datetime_next_day(dt: DateTime) -> DateTime:
|
|
248
|
+
return dt + TimeDelta(days=1)
|
|
249
|
+
|
|
250
|
+
def _datetime_next_month(dt: DateTime) -> DateTime:
|
|
251
|
+
if dt.month == 12:
|
|
252
|
+
return dt.replace(year=dt.year + 1, month=1)
|
|
253
|
+
return dt.replace(month=dt.month + 1)
|
|
254
|
+
|
|
255
|
+
def _datetime_next_year(dt: DateTime) -> DateTime:
|
|
256
|
+
return dt.replace(year=dt.year + 1)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# Test cases
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
# assert _parse_timedelta("1d") == TimeDelta(days=1)
|
|
262
|
+
# assert _parse_timedelta("1d 2h 3m 4s") == TimeDelta(
|
|
263
|
+
# days=1, hours=2, minutes=3, seconds=4
|
|
264
|
+
# )
|
|
265
|
+
# assert _parse_timedelta("1d 2h 3m") == TimeDelta(days=1, hours=2, minutes=3)
|
|
266
|
+
|
|
267
|
+
assert TimeRange.of("2024").is_for_an_exact_year
|
|
268
|
+
|
|
269
|
+
examples = [
|
|
270
|
+
"2024", # Año completo, paso predeterminado (1 día)
|
|
271
|
+
"2024-01", # Mes completo, paso predeterminado (1 día)
|
|
272
|
+
"2024-01-01", # Día completo, paso predeterminado (1 día)
|
|
273
|
+
"2024-01-01T12:00:00", # Fecha y hora exacta
|
|
274
|
+
"2024-01 .. 2024-07", # Rango de un año, paso de 1 hora
|
|
275
|
+
"2024-01-01 .. 2024-07-01", # Rango explícito con delta
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
for example in examples:
|
|
279
|
+
try:
|
|
280
|
+
result = TimeRange.of(example)
|
|
281
|
+
print(f"Input: {example}\nParsed: {result}\n")
|
|
282
|
+
except ValueError as e:
|
|
283
|
+
print(f"Input: {example}\nError: {e}\n")
|
|
284
|
+
|
|
285
|
+
# %%
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class PptxTemplateEngineError(Exception):
|
|
2
|
+
"""Base para errores del motor de plantillas PPTX"""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TemplateSyntaxError(PptxTemplateEngineError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UndefinedVariableError(PptxTemplateEngineError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContextError(PptxTemplateEngineError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TemplateNotFoundError(PptxTemplateEngineError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RenderError(PptxTemplateEngineError):
|
|
23
|
+
pass
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Union, Optional
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from stackraise.model.file import File
|
|
6
|
+
|
|
7
|
+
class ImageData(BaseModel):
|
|
8
|
+
"""Modelo para representar imágenes en el contexto de plantillas PPTX"""
|
|
9
|
+
|
|
10
|
+
# Datos de la imagen
|
|
11
|
+
data: Union[bytes, str, Path] # bytes, ruta como string, o Path object
|
|
12
|
+
|
|
13
|
+
# Metadatos opcionales
|
|
14
|
+
width: Optional[float] = None # Ancho en pulgadas
|
|
15
|
+
height: Optional[float] = None # Alto en pulgadas
|
|
16
|
+
|
|
17
|
+
# Para mantener aspect ratio
|
|
18
|
+
max_width: Optional[float] = None
|
|
19
|
+
max_height: Optional[float] = None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_bytes(cls, data: bytes, width: Optional[float] = None, height: Optional[float] = None):
|
|
23
|
+
"""Crear ImageData desde bytes"""
|
|
24
|
+
return cls(data=data, width=width, height=height)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_path(cls, path: Union[str, Path], width: Optional[float] = None, height: Optional[float] = None):
|
|
28
|
+
"""Crear ImageData desde ruta de archivo"""
|
|
29
|
+
return cls(data=Path(path), width=width, height=height)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_url(cls, url: str, width: Optional[float] = None, height: Optional[float] = None):
|
|
33
|
+
"""Crear ImageData desde URL (requiere descarga previa)"""
|
|
34
|
+
# TODO: Implementar descarga de imagen desde URL
|
|
35
|
+
return cls(data=url, width=width, height=height)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
async def from_file_ref(cls, file_ref: File.Ref):
|
|
39
|
+
file: File = await file_ref.fetch()
|
|
40
|
+
content = await file.content()
|
|
41
|
+
return cls.from_bytes(content)
|
|
42
|
+
|
|
43
|
+
def get_bytes(self) -> bytes:
|
|
44
|
+
"""Obtener los bytes de la imagen"""
|
|
45
|
+
if isinstance(self.data, bytes):
|
|
46
|
+
return self.data
|
|
47
|
+
elif isinstance(self.data, (str, Path)):
|
|
48
|
+
with open(self.data, 'rb') as f:
|
|
49
|
+
return f.read()
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(f"Tipo de data no soportado: {type(self.data)}")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from pptx.slide import Slide
|
|
2
|
+
from pptx.util import Inches
|
|
3
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from ..image import ImageData
|
|
6
|
+
from ..parser import extract_image_variables
|
|
7
|
+
from ..tracer import Tracer
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
Procesa variables de tipo ImageData en slides de PPTX.
|
|
12
|
+
TODO: Refactor para otro tipo de archivos. El tracer y el parser deberian estar a nivel de templating
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
class ImageProcessor:
|
|
16
|
+
"""Procesa variables de tipo ImageData en slides de PPTX"""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def process_slide_images(slide: Slide, context: dict, tracer: Tracer):
|
|
20
|
+
"""
|
|
21
|
+
Procesa todas las variables ImageData en una slide, reemplazando
|
|
22
|
+
los placeholders {{variable}} con las imágenes reales.
|
|
23
|
+
"""
|
|
24
|
+
shapes_to_remove = []
|
|
25
|
+
|
|
26
|
+
for shape in slide.shapes:
|
|
27
|
+
if shape.has_text_frame:
|
|
28
|
+
# Buscar variables ImageData en texto
|
|
29
|
+
text_content = shape.text_frame.text
|
|
30
|
+
image_vars = extract_image_variables(text_content, context)
|
|
31
|
+
|
|
32
|
+
if image_vars:
|
|
33
|
+
# Si hay variables de imagen en este shape
|
|
34
|
+
for image_var in image_vars:
|
|
35
|
+
image_data = ImageProcessor._get_nested_value(context, image_var)
|
|
36
|
+
if isinstance(image_data, ImageData):
|
|
37
|
+
# Verificar si el shape contiene SOLO la variable de imagen
|
|
38
|
+
clean_text = text_content.strip()
|
|
39
|
+
var_pattern = r"""^\{\{\s*""" + re.escape(image_var) + r"""\s*\}\}$"""
|
|
40
|
+
|
|
41
|
+
if re.match(var_pattern, clean_text):
|
|
42
|
+
# El shape contiene SOLO la imagen, reemplazarlo completamente
|
|
43
|
+
ImageProcessor._replace_shape_with_image(
|
|
44
|
+
slide, shape, image_var, image_data, tracer
|
|
45
|
+
)
|
|
46
|
+
shapes_to_remove.append(shape)
|
|
47
|
+
else:
|
|
48
|
+
# El shape tiene texto mixto, insertar imagen cerca
|
|
49
|
+
ImageProcessor._insert_image_near_text(
|
|
50
|
+
slide, shape, image_var, image_data, tracer
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Remover shapes que fueron reemplazados por imágenes
|
|
54
|
+
for shape in shapes_to_remove:
|
|
55
|
+
sp = shape._element
|
|
56
|
+
sp.getparent().remove(sp)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _get_nested_value(context: dict, var_path: str):
|
|
60
|
+
"""
|
|
61
|
+
Obtiene el valor de una variable anidada (ej: client.logo)
|
|
62
|
+
"""
|
|
63
|
+
value = context
|
|
64
|
+
for part in var_path.split('.'):
|
|
65
|
+
value = value[part]
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _replace_shape_with_image(slide: Slide, text_shape, image_var: str, image_data: ImageData, tracer: Tracer):
|
|
70
|
+
"""
|
|
71
|
+
Reemplaza completamente un shape de texto con una imagen.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
# Obtener posición y tamaño del shape de texto
|
|
75
|
+
left = text_shape.left
|
|
76
|
+
top = text_shape.top
|
|
77
|
+
# width = text_shape.width
|
|
78
|
+
# height = text_shape.height
|
|
79
|
+
|
|
80
|
+
# Obtener los bytes de la imagen
|
|
81
|
+
image_bytes = image_data.get_bytes()
|
|
82
|
+
image_stream = BytesIO(image_bytes)
|
|
83
|
+
image_stream.seek(0)
|
|
84
|
+
|
|
85
|
+
# Calcular dimensiones finales
|
|
86
|
+
final_width = Inches(image_data.width) if image_data.width else None
|
|
87
|
+
final_height = Inches(image_data.height) if image_data.height else None
|
|
88
|
+
|
|
89
|
+
# Si hay límites máximos, aplicarlos manteniendo aspect ratio
|
|
90
|
+
if image_data.max_width or image_data.max_height:
|
|
91
|
+
final_width, final_height = ImageProcessor._calculate_constrained_size(
|
|
92
|
+
final_width, final_height, image_data.max_width, image_data.max_height
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Añadir la imagen al slide
|
|
96
|
+
picture = slide.shapes.add_picture(
|
|
97
|
+
image_stream, left, top, final_width, final_height
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
tracer.log_variable(f"{image_var}", f"Imagen insertada ({final_width}x{final_height})")
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
tracer.log_error(f"Error insertando imagen '{image_var}': {e}")
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _insert_image_near_text(slide: Slide, text_shape, image_var: str, image_data: ImageData, tracer: Tracer):
|
|
107
|
+
"""
|
|
108
|
+
Inserta una imagen cerca de un shape de texto que contiene contenido mixto.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
# Calcular posición para la imagen (debajo del texto)
|
|
112
|
+
left = text_shape.left
|
|
113
|
+
top = text_shape.top + text_shape.height + Inches(0.1) # 0.1" de separación
|
|
114
|
+
|
|
115
|
+
# Obtener los bytes de la imagen
|
|
116
|
+
image_bytes = image_data.get_bytes()
|
|
117
|
+
image_stream = BytesIO(image_bytes)
|
|
118
|
+
|
|
119
|
+
# Usar dimensiones especificadas o tamaño por defecto
|
|
120
|
+
final_width = Inches(image_data.width) if image_data.width else Inches(2.0)
|
|
121
|
+
final_height = Inches(image_data.height) if image_data.height else Inches(1.5)
|
|
122
|
+
|
|
123
|
+
# Si hay límites máximos, aplicarlos
|
|
124
|
+
if image_data.max_width or image_data.max_height:
|
|
125
|
+
final_width, final_height = ImageProcessor._calculate_constrained_size(
|
|
126
|
+
final_width, final_height, image_data.max_width, image_data.max_height
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Añadir la imagen al slide
|
|
130
|
+
picture = slide.shapes.add_picture(
|
|
131
|
+
image_stream, left, top, final_width, final_height
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
tracer.log_variable(f"{image_var}", f"Imagen insertada cerca del texto ({final_width}x{final_height})")
|
|
135
|
+
|
|
136
|
+
except Exception as e:
|
|
137
|
+
tracer.log_error(f"Error insertando imagen '{image_var}': {e}")
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _calculate_constrained_size(width, height, max_width=None, max_height=None):
|
|
141
|
+
"""
|
|
142
|
+
Calcula el tamaño final manteniendo aspect ratio dentro de los límites.
|
|
143
|
+
"""
|
|
144
|
+
if max_width:
|
|
145
|
+
max_width = Inches(max_width)
|
|
146
|
+
if max_height:
|
|
147
|
+
max_height = Inches(max_height)
|
|
148
|
+
|
|
149
|
+
# Calcular factor de escala
|
|
150
|
+
scale_x = max_width / width if max_width and width > max_width else 1
|
|
151
|
+
scale_y = max_height / height if max_height and height > max_height else 1
|
|
152
|
+
scale = min(scale_x, scale_y)
|
|
153
|
+
|
|
154
|
+
return width * scale, height * scale
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from jinja2 import Environment, meta, StrictUndefined
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
def create_jinja_env():
|
|
6
|
+
return Environment(
|
|
7
|
+
undefined=StrictUndefined,
|
|
8
|
+
autoescape=False,
|
|
9
|
+
trim_blocks=True,
|
|
10
|
+
lstrip_blocks=True
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def extract_variables_from_text(text: str, jinja_env: Environment):
|
|
14
|
+
ast = jinja_env.parse(text)
|
|
15
|
+
return meta.find_undeclared_variables(ast)
|
|
16
|
+
|
|
17
|
+
def extract_slide_loop(text: str) -> Optional[tuple[str, str, str]]:
|
|
18
|
+
#pattern = r"""\{%\s*for\s+(\w+)\s+(of|in)\s+(\w+)\s*%\}"""
|
|
19
|
+
pattern = r"""\{%\s*for\s+(\w+)\s+(of|in)\s+(.+?)\s*%\}"""
|
|
20
|
+
match = re.search(pattern, text)
|
|
21
|
+
if match:
|
|
22
|
+
var, tipo, lista = match.groups()
|
|
23
|
+
return var, lista, tipo
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def extract_image_variables(text: str, context: dict) -> List[str]:
|
|
27
|
+
"""
|
|
28
|
+
Extrae variables que son de tipo ImageData del contexto
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .image.model import ImageData
|
|
32
|
+
|
|
33
|
+
# Buscar todas las variables en el texto
|
|
34
|
+
pattern = r"""\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*\}\}"""
|
|
35
|
+
variables = re.findall(pattern, text)
|
|
36
|
+
|
|
37
|
+
# Filtrar solo las que son ImageData
|
|
38
|
+
image_vars = []
|
|
39
|
+
for var in variables:
|
|
40
|
+
# Navegar por variables anidadas (ej: client.logo)
|
|
41
|
+
value = context
|
|
42
|
+
try:
|
|
43
|
+
for part in var.split('.'):
|
|
44
|
+
value = value[part]
|
|
45
|
+
if isinstance(value, ImageData):
|
|
46
|
+
image_vars.append(var)
|
|
47
|
+
except (KeyError, TypeError):
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
return image_vars
|
|
51
|
+
|
|
52
|
+
def has_image_variables(text: str, context: dict) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Verifica si el texto contiene variables de tipo ImageData
|
|
55
|
+
"""
|
|
56
|
+
return len(extract_image_variables(text, context)) > 0
|
|
57
|
+
|
|
58
|
+
def preprocess_text_for_jinja(text: str, context: dict = None) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Preprocesa el texto para convertir 'of' a 'in' en bucles de texto
|
|
61
|
+
y remover variables ImageData (se procesan por separado)
|
|
62
|
+
"""
|
|
63
|
+
# Convertir {% for var of list %} a {% for var in list %}
|
|
64
|
+
#pattern = r"""(\{%\s*for\s+\w+\s+)of(\s+\w+\s*%\})"""
|
|
65
|
+
pattern = r"""(\{%\s*for\s+\w+\s+)of(\s+.+?\s*%\})"""
|
|
66
|
+
converted_text = re.sub(pattern, r'\1in\2', text)
|
|
67
|
+
|
|
68
|
+
# Si tenemos contexto, remover variables ImageData
|
|
69
|
+
if context:
|
|
70
|
+
image_vars = extract_image_variables(text, context)
|
|
71
|
+
for var in image_vars:
|
|
72
|
+
# Reemplazar {{imagen_var}} con placeholder vacío
|
|
73
|
+
var_pattern = r"""\{\{\s*""" + re.escape(var) + r"""\s*\}\}"""
|
|
74
|
+
converted_text = re.sub(var_pattern, "", converted_text)
|
|
75
|
+
|
|
76
|
+
return converted_text
|
|
77
|
+
|
|
78
|
+
def extract_slide_conditional_block(text: str) -> Optional[Dict[str, Any]]:
|
|
79
|
+
"""
|
|
80
|
+
Extrae un bloque condicional completo incluyendo if/elif/else.
|
|
81
|
+
Retorna un diccionario con la estructura del condicional.
|
|
82
|
+
"""
|
|
83
|
+
# Patrón para capturar bloques if completos con elif y else opcionales
|
|
84
|
+
pattern = r"""\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*elif\s+(.+?)\s*%\}(.*?))*(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}"""
|
|
85
|
+
|
|
86
|
+
match = re.search(pattern, text, re.DOTALL)
|
|
87
|
+
if match:
|
|
88
|
+
if_condition = match.group(1).strip()
|
|
89
|
+
return {
|
|
90
|
+
'type': 'conditional_block',
|
|
91
|
+
'if_condition': if_condition,
|
|
92
|
+
'has_block_structure': True
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Patrón más simple para detectar si hay una condición en la slide
|
|
96
|
+
simple_pattern = r"""\{%\s*if\s+(.+?)\s*%\}"""
|
|
97
|
+
simple_match = re.search(simple_pattern, text)
|
|
98
|
+
if simple_match:
|
|
99
|
+
condition = simple_match.group(1).strip()
|
|
100
|
+
return {
|
|
101
|
+
'type': 'simple_conditional',
|
|
102
|
+
'condition': condition,
|
|
103
|
+
'has_block_structure': False
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def extract_slide_conditional(text: str) -> Optional[str]:
|
|
109
|
+
"""
|
|
110
|
+
Extrae la condición de un bloque if en una slide.
|
|
111
|
+
Retorna la condición si encuentra un bloque {% if condition %}
|
|
112
|
+
"""
|
|
113
|
+
pattern = r"""\{%\s*if\s+(.+?)\s*%\}"""
|
|
114
|
+
match = re.search(pattern, text)
|
|
115
|
+
if match:
|
|
116
|
+
condition = match.group(1).strip()
|
|
117
|
+
return condition
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def evaluate_condition(condition: str, context: dict, jinja_env: Environment) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Evalúa una condición de Jinja2 contra el contexto dado.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
# Crear una plantilla simple que evalúe la condición
|
|
126
|
+
template_text = f"{{% if {condition} %}}TRUE{{% else %}}FALSE{{% endif %}}"
|
|
127
|
+
template = jinja_env.from_string(template_text)
|
|
128
|
+
result = template.render(context)
|
|
129
|
+
return result == "TRUE"
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise ValueError(f"Error evaluando condición '{condition}': {e}")
|
|
132
|
+
|
|
133
|
+
def evaluate_conditional_block(text: str, context: dict, jinja_env: Environment) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Evalúa un bloque condicional completo con if/elif/else.
|
|
136
|
+
Retorna True si alguna condición se cumple o hay un else.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
# Preprocesar para convertir 'of' a 'in'
|
|
140
|
+
processed_text = preprocess_text_for_jinja(text)
|
|
141
|
+
# Renderizar el bloque completo y ver si produce contenido
|
|
142
|
+
template = jinja_env.from_string(processed_text)
|
|
143
|
+
result = template.render(context).strip()
|
|
144
|
+
# Si el resultado no está vacío, significa que alguna condición se cumplió
|
|
145
|
+
return bool(result)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise ValueError(f"Error evaluando bloque condicional: {e}")
|
|
148
|
+
|
|
149
|
+
def render_text(text: str, context: dict, jinja_env: Environment) -> str:
|
|
150
|
+
try:
|
|
151
|
+
# Preprocesar para convertir 'of' a 'in' y remover ImageData
|
|
152
|
+
processed_text = preprocess_text_for_jinja(text, context)
|
|
153
|
+
template = jinja_env.from_string(processed_text)
|
|
154
|
+
return template.render(context)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise
|