motulco 0.2.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.

Potentially problematic release.


This version of motulco might be problematic. Click here for more details.

motulco/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from .colors_utils import *
2
+ from .df_utils import *
3
+ from .doc_utils import *
4
+ from .excel_utils import *
5
+ from .pdf_utils import *
6
+ from .ppt_utils import *
7
+ from .py_utils import *
8
+ from .shapes_utils import *
@@ -0,0 +1,12 @@
1
+
2
+
3
+
4
+ ################################################################################################################################
5
+ ################################################################################################################################
6
+ ####################################################### Convierte de HEX a RGB #################################################
7
+ ################################################################################################################################
8
+ ################################################################################################################################
9
+ def hex_to_rgb(hex_color):
10
+ """Convierte color hex (#RRGGBB) a tupla RGB (R,G,B)"""
11
+ hex_color = hex_color.lstrip("#")
12
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
motulco/df_utils.py ADDED
@@ -0,0 +1,185 @@
1
+ import pandas as pd
2
+ import os
3
+ from unidecode import unidecode
4
+ from openpyxl import load_workbook
5
+
6
+ ################################################################################################################################
7
+ ################################################################################################################################
8
+ ################################# Quita los .0 en los textos de un df en las columnas dadas ####################################
9
+ ################################################################################################################################
10
+ ################################################################################################################################
11
+ def QuitarPuntosDecimalesTextos(df, columnas):
12
+ """Convierte las columnas especificadas a string y elimina el sufijo '.0' de los valores numéricos.
13
+ Returns:
14
+ pd.DataFrame: El DataFrame con las columnas modificadas.
15
+ """
16
+ df[columnas] = df[columnas].astype(str).replace(r'^(\d+)\.0$', r'\1', regex=True)
17
+ return df
18
+
19
+ ################################################################################################################################
20
+ ################################################################################################################################
21
+ ################################## Hace la union (merge/join) de df1 y df2 y valida cruces ####################################
22
+ ################################################################################################################################
23
+ ################################################################################################################################
24
+ def mergeBases(dataframe1, dataframe2, lefton, righton):
25
+ """Une dos DataFrames de Pandas asegurando que no haya duplicados en el cruce y detectando registros sin coincidencias en cada DataFrame.
26
+
27
+ Parámetros:
28
+ - dataframe1: Primer DataFrame de Pandas.
29
+ - dataframe2: Segundo DataFrame de Pandas.
30
+ - lefton: Lista de columnas clave en dataframe1.
31
+ - righton: Lista de columnas clave en dataframe2.
32
+
33
+ Retorna:
34
+ - dfmerge: DataFrame con la unión.
35
+ - df_no_match_left: Registros en dataframe1 que no encontraron match en dataframe2.
36
+ - df_no_match_right: Registros en dataframe2 que no encontraron match en dataframe1.
37
+ - alerta_duplicados: Mensaje de alerta si hay duplicados en la clave.
38
+ """
39
+
40
+ # Realizar el merge con how='left'
41
+ if lefton == righton:
42
+ dfmerge = pd.merge(dataframe1, dataframe2, on=lefton, how='left',suffixes=('','_right'))
43
+ else:
44
+ dfmerge = pd.merge(dataframe1, dataframe2, left_on=lefton, right_on=righton, how='left', suffixes=('','_right'))
45
+
46
+ # Eliminar columnas duplicadas generadas por el merge
47
+ if isinstance(righton, list): # Si righton es una lista de columnas
48
+ for col in righton:
49
+ columna_duplicada = f"{col}_right"
50
+ if columna_duplicada in dfmerge.columns:
51
+ dfmerge = dfmerge.drop(columns=[columna_duplicada])
52
+ else: # Si righton es una sola columna
53
+ columna_duplicada = f"{righton}_right"
54
+ if columna_duplicada in dfmerge.columns:
55
+ dfmerge = dfmerge.drop(columns=[columna_duplicada])
56
+
57
+ # Detectar registros de df1 que no tienen coincidencias en df2 (valores NaN en columnas de df2)
58
+ columnas_df2 = [col for col in dataframe2.columns if col not in righton]
59
+ df_no_match_left = dfmerge[dfmerge[columnas_df2].isna().all(axis=1)]
60
+
61
+ # Detectar registros de df2 que no tienen coincidencias en df1
62
+ claves_merge = set(dfmerge[lefton].drop_duplicates().itertuples(index=False, name=None))
63
+ df_no_match_right = dataframe2[~dataframe2[righton].apply(lambda x: tuple(x) in claves_merge, axis=1)]
64
+
65
+ # Detectar duplicados en las claves del merge
66
+ duplicados = dfmerge.groupby(lefton).size()
67
+ duplicados = duplicados[duplicados > 1] # Filtrar claves duplicadas
68
+
69
+ alerta_duplicados = "⚠️ Hay duplicados en la clave del cruce" if len(duplicados) > 0 else "✅ No hay duplicados en la clave"
70
+
71
+ return dfmerge, df_no_match_left, df_no_match_right, alerta_duplicados
72
+
73
+ ################################################################################################################################
74
+ ################################################################################################################################
75
+ ################################### Quita las tildes del contenido de un DataFrame ############################################
76
+ ################################################################################################################################
77
+ ################################################################################################################################
78
+ def dfSinTildes(dataframe):
79
+ for columna in dataframe.columns:
80
+ if dataframe[columna].dtype == 'object':
81
+ dataframe[columna] = dataframe[columna].apply(unidecode)
82
+ return dataframe
83
+
84
+ ################################################################################################################################
85
+ ################################################################################################################################
86
+ ################################### Quita las tildes de los encabezados de un DataFrame ######################################
87
+ ################################################################################################################################
88
+ ################################################################################################################################
89
+ def dfEncabezadosSinTildes(dataframe):
90
+ nuevos_nombres_columnas = [unidecode(columna)
91
+ if dataframe[columna].dtype == 'object' else columna for columna in dataframe.columns]
92
+ dataframe.columns = nuevos_nombres_columnas
93
+ return dataframe
94
+
95
+
96
+ ################################################################################################################################
97
+ ################################################################################################################################
98
+ ############################### Guarda un df como un excel en caso de existir reemplaza ######################################
99
+ ################################################################################################################################
100
+ ################################################################################################################################
101
+ def GuardarRemplazarExcel(dataframe,rutaParaGuardado,nombreHoja, ejecutar=True):
102
+ if ejecutar:
103
+ if os.path.exists(rutaParaGuardado):
104
+ os.remove(rutaParaGuardado)
105
+ dataframe.to_excel(rutaParaGuardado,sheet_name=nombreHoja, index=False)
106
+
107
+ ################################################################################################################################
108
+ ################################################################################################################################
109
+ ############################### Guarda un df como un CSV en caso de existir reemplaza ########################################
110
+ ################################################################################################################################
111
+ ################################################################################################################################
112
+ def GuardarRemplazarCsv(dataframe,rutaParaGuardado, ejecutar=True):
113
+ if ejecutar:
114
+ if os.path.exists(rutaParaGuardado):
115
+ os.remove(rutaParaGuardado)
116
+ dataframe.to_csv(rutaParaGuardado, index=False)
117
+
118
+ ################################################################################################################################
119
+ ################################################################################################################################
120
+ ############################### Guarda un df como un parquet en caso de existir reemplaza ####################################
121
+ ################################################################################################################################
122
+ ################################################################################################################################
123
+ def GuardarRemplazarParquet(dataframe,rutaParaGuardado, ejecutar=True, engine="fastparquet"):
124
+ if ejecutar:
125
+ if os.path.exists(rutaParaGuardado):
126
+ os.remove(rutaParaGuardado)
127
+ if engine=="":
128
+ dataframe.to_parquet(rutaParaGuardado, index=False)
129
+ else:
130
+ dataframe.to_parquet(rutaParaGuardado, index=False, engine=engine)
131
+
132
+ ################################################################################################################################
133
+ ################################################################################################################################
134
+ ######################################################### Convierte columnas en tipo bool ####################################
135
+ ################################################################################################################################
136
+ ################################################################################################################################
137
+ def EstandarizarColumnasConDatosBool(dataframe):
138
+ for col in dataframe.columns:
139
+ # Si en la columna hay al menos un valor booleano
140
+ if dataframe[col].apply(lambda x: isinstance(x, bool)).any():
141
+ # Reemplazar NaN con False y convertir todo a tipo bool
142
+ dataframe[col] = dataframe[col].astype(object).fillna(False).astype(bool)
143
+ return dataframe
144
+
145
+ ################################################################################################################################
146
+ ################################################################################################################################
147
+ ######################################################### Convierte columnas en tipo bool ####################################
148
+ ################################################################################################################################
149
+ ################################################################################################################################
150
+ def extraerMesInicioDelNombre(nombreArchivo,Separador):
151
+ """ Extrae el número y nombre del mes desde el nombre del archivo. """
152
+ nombreArchivo = nombreArchivo.replace(" ", "") # Elimina espacios extra
153
+ partes = nombreArchivo.split(Separador)
154
+ if len(partes) >= 1:
155
+ numero_mes = partes[0].strip() # "01"
156
+ return numero_mes
157
+
158
+ return None, None # Retornar valores nulos si el formato no es el esperado
159
+
160
+ ################################################################################################################################
161
+ ################################################################################################################################
162
+ ################ Funcion que convierte una cadena a (UpperCamelCase), eliminando espacios y guiones bajos. ###################
163
+ ################################################################################################################################
164
+ ################################################################################################################################
165
+ def pasarACamelCase(texto,Separador):
166
+ # Reemplaza guiones bajos por espacios, divide en palabras, y las capitaliza
167
+ partes = texto.replace(Separador, " ").split()
168
+ return ''.join(p.capitalize() for p in partes)
169
+
170
+
171
+ ################################################################################################################################
172
+ ################################################################################################################################
173
+ ################ Renombrar columnas de un df con poniendo en CamelCase lo que esta a la izquierda del separador ###############
174
+ ################################################################################################################################
175
+ ################################################################################################################################
176
+ def renombraColumnasTablas(dataframe,Separador):
177
+ nuevas_columnas = {}
178
+ for col in dataframe.columns:
179
+ if "_" in col:
180
+ izquierda, derecha = col.split("_", 1) # Solo divide en el primer guion bajo
181
+ nuevo_nombre = f"{pasarACamelCase(izquierda,Separador)}_{derecha}"
182
+ else:
183
+ nuevo_nombre = pasarACamelCase(col,Separador)
184
+ nuevas_columnas[col] = nuevo_nombre
185
+ return dataframe.rename(columns=nuevas_columnas)
motulco/doc_utils.py ADDED
@@ -0,0 +1,84 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import os
4
+ import shutil
5
+ from unidecode import unidecode
6
+
7
+
8
+ ################################################################################################################################
9
+ ################################################################################################################################
10
+ ####################################### Borrar los archivos si existen en la carpeta ###########################################
11
+ ################################################################################################################################
12
+ ################################################################################################################################
13
+ def BorrrarArchivo(rutaArchivo):
14
+ if os.path.exists(rutaArchivo):# Verificar si el archivo ya existe
15
+ os.remove(rutaArchivo)
16
+
17
+ ################################################################################################################################
18
+ ################################################################################################################################
19
+ ################################# Mueve los archivos descargados a la carpeta que se requiere ##################################
20
+ ################################################################################################################################
21
+ ################################################################################################################################
22
+ def moverArchivo(RutaOrigen, RutaDestino):
23
+ try:
24
+ shutil.move(RutaOrigen, RutaDestino)
25
+ print(f"El archivo '{RutaOrigen}' se ha movido correctamente a '{RutaDestino}'.")
26
+ except Exception as e:
27
+ print(f"No se pudo mover el archivo '{RutaOrigen}' a '{RutaDestino}': {e}")
28
+
29
+ ################################################################################################################################
30
+ ################################################################################################################################
31
+ ############################################### Copia un archivo de un destino a otro ##########################################
32
+ ################################################################################################################################
33
+ ################################################################################################################################
34
+ def copiarArchivo(RutaOrigen, RutaDestino):
35
+ try:
36
+ shutil.copy2(RutaOrigen, RutaDestino) # `copy2` copia metadatos (marca de tiempo) además del archivo
37
+ print(f"El archivo '{RutaOrigen}' se ha copiado correctamente a '{RutaDestino}'.")
38
+ except Exception as e:
39
+ print(f"No se pudo copiar el archivo '{RutaOrigen}' a '{RutaDestino}': {e}")
40
+
41
+ ################################################################################################################################
42
+ ################################################################################################################################
43
+ ###################################### Obtiene el archivo más reciente de una carpeta ##########################################
44
+ ################################################################################################################################
45
+ ################################################################################################################################
46
+ def obtenerArchivoMasReciente(rutaCarpeta, formato=".txt"):
47
+ # Obtener la lista de archivos en la carpeta
48
+ archivos_en_carpeta = [archivo for archivo in os.listdir(rutaCarpeta) if archivo.endswith(formato)]
49
+ # Verificar si hay archivos en la carpeta
50
+ if archivos_en_carpeta:
51
+ # Obtener el archivo más reciente basado en la última modificación
52
+ archivo_mas_reciente = max(archivos_en_carpeta,key=lambda x: os.path.getmtime(os.path.join(rutaCarpeta, x)))
53
+ # Obtener la fecha de modificación del archivo más reciente
54
+ fecha_modificacion = os.path.getmtime(os.path.join(rutaCarpeta, archivo_mas_reciente))
55
+ # Retornar nombre y fecha
56
+ return archivo_mas_reciente, fecha_modificacion
57
+ else:
58
+ return None, None
59
+
60
+ ################################################################################################################################
61
+ ################################################################################################################################
62
+ ###################################### Retorna la ruta que exista del arreglo de rutas #########################################
63
+ ################################################################################################################################
64
+ ################################################################################################################################
65
+ def ExisteRuta(arregloDeRutas):
66
+ carpeta_correcta = None
67
+ for carpeta in arregloDeRutas:
68
+ try:
69
+ # Verifica si la ruta existe y es una carpeta
70
+ if os.path.isdir(carpeta):
71
+ print(f"La carpeta existe en: {carpeta}")
72
+ carpeta_correcta = carpeta
73
+ break # Sale del ciclo si la carpeta existe
74
+ except Exception as e:
75
+ # Maneja otras posibles excepciones (e.g., problemas de permisos)
76
+ print(f"Error al verificar la carpeta {carpeta}: {e}")
77
+ continue
78
+ if carpeta_correcta is None:
79
+ print("La carpeta no se encontró en ninguna de las rutas proporcionadas.")
80
+ else:
81
+ # Aquí puedes continuar con el procesamiento usando la carpeta_correcta
82
+ pass
83
+ return carpeta_correcta
84
+
motulco/excel_utils.py ADDED
@@ -0,0 +1,126 @@
1
+ import os
2
+ import pandas as pd
3
+ import openpyxl
4
+ import time
5
+ import pythoncom
6
+ import win32com.client
7
+ from openpyxl import load_workbook
8
+
9
+
10
+ ################################################################################################################################
11
+ ################################################################################################################################
12
+ ############################################### Carga un archivo de excel como un df ###########################################
13
+ ################################################################################################################################
14
+ ################################################################################################################################
15
+ def cargaExcel(rutaArchivo,sheetName, AsString=False,engine='openpyxl'):
16
+ df = pd.read_excel(rutaArchivo, sheet_name=sheetName, engine=engine)
17
+ if AsString:
18
+ df=df.astype(str)
19
+ return df
20
+
21
+ ################################################################################################################################
22
+ ################################################################################################################################
23
+ ############################################# Ejecuta la funcion Actualizar todo de excel ######################################
24
+ ################################################################################################################################
25
+ ################################################################################################################################
26
+
27
+ def ActualizarTodoExcel(rutaArchivo):
28
+ pythoncom.CoInitialize() # Inicializa COM correctamente
29
+
30
+ try:
31
+ rutaArchivoAbsoluta = os.path.abspath(rutaArchivo)
32
+ print(f"Intentando abrir el archivo: {rutaArchivoAbsoluta}")
33
+
34
+ if not os.path.exists(rutaArchivoAbsoluta):
35
+ print(f"⚠️ El archivo no existe en la ruta: {rutaArchivoAbsoluta}")
36
+ return
37
+ # Iniciar Excel y hacerlo visible
38
+ excel_app = win32com.client.DispatchEx("Excel.Application")
39
+ excel_app.Visible = True
40
+ excel_app.DisplayAlerts = False
41
+ # Abrir el archivo de Excel
42
+ workbook = excel_app.Workbooks.Open(rutaArchivoAbsoluta)
43
+ print("📂 Archivo abierto correctamente, iniciando actualización...")
44
+
45
+ # Activar la primera hoja (por si acaso)
46
+ workbook.Sheets(1).Activate()
47
+ # Iniciar actualización
48
+ workbook.RefreshAll()
49
+ print("🔄 Actualizando conexiones y tablas de Power Query...")
50
+ # Esperar que Excel termine la actualización y cálculos
51
+ while excel_app.CalculationState != 0: # 0 significa "listo"
52
+ print("⌛ Esperando que Excel termine los cálculos...")
53
+ time.sleep(2)
54
+ print("✅ Actualización completada.")
55
+ # Guardar y cerrar Excel
56
+ print(f'📁 Archivo actualizado y cerrado: {rutaArchivoAbsoluta}')
57
+ except Exception as e:
58
+ print(f"❌ Error al actualizar el archivo {rutaArchivoAbsoluta}: {e}")
59
+ finally:
60
+ # Asegurar que Excel se cierra correctamente
61
+ if 'excel_app' in locals():
62
+ a=10
63
+ pythoncom.CoUninitialize()
64
+
65
+ ################################################################################################################################
66
+ ################################################################################################################################
67
+ #################################### Consolida Archivos de excel cuyo nombre es semejante ######################################
68
+ ################################################################################################################################
69
+ ################################################################################################################################
70
+ def consolidarArchivosExcelPrefijo(NombreConElQueIniciaLosArchivos,RutaALaCarpeta,TipoArchivos='.xlsx'):
71
+ dataframes = []
72
+ for archivo in os.listdir(RutaALaCarpeta):
73
+ if archivo.endswith(TipoArchivos) and archivo.startswith(NombreConElQueIniciaLosArchivos):
74
+ ruta_archivo = os.path.join(RutaALaCarpeta, archivo)
75
+ df = pd.read_excel(ruta_archivo)
76
+ dataframes.append(df)
77
+ df_consolidado = pd.concat(dataframes, ignore_index=True)
78
+ return df_consolidado
79
+
80
+ ################################################################################################################################
81
+ ################################################################################################################################
82
+ ############################################## Consolida Archivos de excel en una carpeta ######################################
83
+ ################################################################################################################################
84
+ ################################################################################################################################
85
+ def consolidarArchivosExcelGlobal(NombreConElQueIniciaLosArchivos,RutaALaCarpeta,TipoArchivos='.xlsx'):
86
+ dataframes = []
87
+ for archivo in os.listdir(RutaALaCarpeta):
88
+ if archivo.endswith(TipoArchivos):
89
+ ruta_archivo = os.path.join(RutaALaCarpeta, archivo)
90
+ df = pd.read_excel(ruta_archivo)
91
+ dataframes.append(df)
92
+ df_consolidado = pd.concat(dataframes, ignore_index=True)
93
+ return df_consolidado
94
+
95
+
96
+ ################################################################################################################################
97
+ ################################################################################################################################
98
+ ################################## Funcion que retornas el arreglo de hojas visibles (no oculta) en excel ###################
99
+ ################################################################################################################################
100
+ ################################################################################################################################
101
+ def obtenerHojasVisiblesExcel(RutaArchivo):
102
+ """Obtiene solo las hojas visibles de un archivo Excel (.xlsx)."""
103
+ try:
104
+ wb = load_workbook(RutaArchivo, read_only=True)
105
+ hojas_visibles = [sheet.title for sheet in wb.worksheets if sheet.sheet_state == "visible"]
106
+ wb.close()
107
+ return hojas_visibles
108
+ except Exception as e:
109
+ print(f"Error al leer hojas de {RutaArchivo}: {e}")
110
+ return []
111
+
112
+ ################################################################################################################################
113
+ ################################################################################################################################
114
+ ################################## Funcion que retornas el arreglo de todas las hojas en excel ##############################
115
+ ################################################################################################################################
116
+ ################################################################################################################################
117
+ def obtenerTodasLasHojasExcel(RutaArchivo):
118
+ """Obtiene todas las hojas de un archivo Excel (.xlsx), sin importar si están visibles u ocultas."""
119
+ try:
120
+ wb = load_workbook(RutaArchivo, read_only=True)
121
+ hojas = [sheet.title for sheet in wb.worksheets] # Todas las hojas
122
+ wb.close()
123
+ return hojas
124
+ except Exception as e:
125
+ print(f"Error al leer hojas de {RutaArchivo}: {e}")
126
+ return []
motulco/pdf_utils.py ADDED
@@ -0,0 +1,29 @@
1
+ from pypdf import PdfReader, PdfWriter
2
+
3
+
4
+
5
+ ################################################################################################################################
6
+ ################################################################################################################################
7
+ ####################################### Rota los grados y paginas deseadas de un pdf ###########################################
8
+ ################################################################################################################################
9
+ ################################################################################################################################
10
+ def rotarPaginasPdf(pdf_entrada, pdf_salida, paginas_a_rotar, grados):
11
+ """
12
+ Rota páginas específicas de un PDF.
13
+
14
+ pdf_entrada: str -> ruta del PDF original
15
+ pdf_salida: str -> ruta del PDF modificado
16
+ paginas_a_rotar: list[int] -> índices de páginas a rotar (0 = primera)
17
+ grados: int -> grados de rotación (90, 180, 270)
18
+ """
19
+
20
+ reader = PdfReader(pdf_entrada)
21
+ writer = PdfWriter()
22
+
23
+ for i, page in enumerate(reader.pages):
24
+ if i in paginas_a_rotar:
25
+ page.rotate(grados)
26
+ writer.add_page(page)
27
+
28
+ with open(pdf_salida, "wb") as f:
29
+ writer.write(f)
motulco/ppt_utils.py ADDED
@@ -0,0 +1,309 @@
1
+
2
+ import os
3
+ import shutil
4
+ from PIL import Image
5
+ import pandas as pd
6
+
7
+
8
+ ################################################################################################################################
9
+ ################################################################################################################################
10
+ ################################################# Abre una diapositiva existente ###############################################
11
+ ################################################################################################################################
12
+ ################################################################################################################################
13
+ def abrirPresentacion(RutaPpt):
14
+ # --- 1️⃣ Limpiar la caché COM dañada ---
15
+ gen_py_path = os.path.join(os.environ["LOCALAPPDATA"], "Temp", "gen_py")
16
+ if os.path.exists(gen_py_path):
17
+ try:
18
+ shutil.rmtree(gen_py_path, ignore_errors=True)
19
+ except Exception as e:
20
+ print(f"No se pudo eliminar la caché gen_py: {e}")
21
+
22
+ # --- 2️⃣ Regenerar caché de COM ---
23
+ import win32com.client.gencache
24
+ win32com.client.gencache.is_readonly = False
25
+ try:
26
+ win32com.client.gencache.Rebuild()
27
+ except Exception:
28
+ pass # A veces da advertencias inofensivas
29
+
30
+ # --- 3️⃣ Crear o usar instancia de PowerPoint ---
31
+ try:
32
+ ppt_app = win32com.client.GetActiveObject("PowerPoint.Application")
33
+ except Exception:
34
+ ppt_app = win32com.client.Dispatch("PowerPoint.Application")
35
+
36
+ ppt_app.Visible = True
37
+
38
+ # --- 4️⃣ Abrir la presentación plantilla ---
39
+ pres_src = ppt_app.Presentations.Open(RutaPpt)
40
+
41
+ return ppt_app, pres_src
42
+
43
+ ################################################################################################################################
44
+ ################################################################################################################################
45
+ ################################################# Crea un libro de ppt en blanco ###############################################
46
+ ################################################################################################################################
47
+ ################################################################################################################################
48
+ def CrearPresentacion(pptDestino):
49
+ import time
50
+ import win32com.client as win32
51
+
52
+ try:
53
+ ppt_app = win32.gencache.EnsureDispatch("PowerPoint.Application")
54
+ except Exception:
55
+ # Si falla, limpia la caché y usa Dispatch
56
+ import os, shutil
57
+ gen_py_path = os.path.join(os.environ["LOCALAPPDATA"], "Temp", "gen_py")
58
+ shutil.rmtree(gen_py_path, ignore_errors=True)
59
+ ppt_app = win32.Dispatch("PowerPoint.Application")
60
+
61
+ ppt_app.Visible = True
62
+ pres_dst = ppt_app.Presentations.Add()
63
+ pres_dst.SaveAs(pptDestino)
64
+ time.sleep(1)
65
+ return ppt_app, pres_dst
66
+
67
+ ################################################################################################################################
68
+ ################################################################################################################################
69
+ ################################################# Copia diapositivas entre ppt's ###############################################
70
+ ################################################################################################################################
71
+ ################################################################################################################################
72
+ def copiarDiaposPosicion(pres_src, pres_dst, posicionesCopiar):
73
+ # ✅ Ajustar tamaño de página igual al origen
74
+ pres_dst.PageSetup.SlideWidth = pres_src.PageSetup.SlideWidth
75
+ pres_dst.PageSetup.SlideHeight = pres_src.PageSetup.SlideHeight
76
+
77
+ for pos in posicionesCopiar:
78
+ pres_src.Slides(pos + 1).Copy() # Copiar (recordar que es base 1)
79
+ pres_dst.Slides.Paste() # Pegar manteniendo formato
80
+
81
+ #pres_dst.Save() # ✅ Guardar cambios
82
+
83
+ ################################################################################################################################
84
+ ################################################################################################################################
85
+ ######################################################## Guarda una presentacion ###############################################
86
+ ################################################################################################################################
87
+ ################################################################################################################################
88
+ def guardarPpt(pres_dst):
89
+ pres_dst.Save()
90
+
91
+
92
+ ################################################################################################################################
93
+ ################################################################################################################################
94
+ #################################### Obtienene la ultima posicion Vertical de la ppt ###########################################
95
+ ################################################################################################################################
96
+ ################################################################################################################################
97
+ def getNextY(slide, padding=5):
98
+ """ Calcula la siguiente posición disponible en Y dentro de una diapositiva """
99
+ max_y = 0
100
+ for shape in slide.Shapes:
101
+ bottom = shape.Top + shape.Height
102
+ if bottom > max_y:
103
+ max_y = bottom
104
+ return max_y + padding
105
+
106
+ ################################################################################################################################
107
+ ################################################################################################################################
108
+ ########################################## Obtienene la cantidad de slides en la ppt ###########################################
109
+ ################################################################################################################################
110
+ ################################################################################################################################
111
+ def obtenerDiapositivaMaxima(presentacion):
112
+ try:
113
+ return presentacion.Slides.Count
114
+ except:
115
+ return None
116
+
117
+
118
+
119
+ ################################################################################################################################
120
+ ################################################################################################################################
121
+ ################################################# Agregar Elemento (shape) a una ppt ###########################################
122
+ ################################################################################################################################
123
+ ################################################################################################################################
124
+ def agregarElemento(pres_dst, pres_src, num_slide, tipo, contenido, x=50, y="auto",
125
+ ancho=300, alto=100, font_size=20,
126
+ font_name="Arial", bold=False, italic=False,
127
+ color=(0, 0, 0), align="left",
128
+ crop_top=0, crop_bottom=0, crop_left=0, crop_right=0,
129
+ auto_crop=False, mantener_relacion=True, mantener_relacion_base="ancho",
130
+ paddingSet=5, rotacion=0):
131
+ """
132
+ Inserta texto o imagen en una diapositiva de PowerPoint.
133
+ Ahora incluye rotación para imágenes vía rotacion=grados.
134
+ """
135
+ puntosACentimetros=28.3465
136
+ slide = pres_dst.Slides(num_slide)
137
+ slide_width = pres_dst.PageSetup.SlideWidth
138
+ slide_height = pres_dst.PageSetup.SlideHeight
139
+
140
+ # ======== Calcular Y inicial ========
141
+ if y == "auto":
142
+ y = getNextY(slide, paddingSet) - crop_top * puntosACentimetros * 6
143
+
144
+ # ======== Auto crop ========
145
+ if auto_crop:
146
+ crop_top = 0.05
147
+ crop_bottom = 0.05
148
+ crop_left = 0.03
149
+ crop_right = 0.03
150
+
151
+ # ======== Mantener relación si es imagen ========
152
+ if tipo == "imagen" and mantener_relacion:
153
+ try:
154
+ with Image.open(contenido) as img:
155
+ width_orig, height_orig = img.size
156
+ aspect_ratio = width_orig / height_orig
157
+
158
+ if mantener_relacion_base == "ancho":
159
+ # Ajustar alto usando el ancho como referencia
160
+ alto = ancho / aspect_ratio
161
+
162
+ elif mantener_relacion_base == "alto":
163
+ # Ajustar ancho usando el alto como referencia
164
+ ancho = alto * aspect_ratio
165
+
166
+ else:
167
+ print(f"⚠️ Opción inválida en mantener_relacion_base: {mantener_relacion_base}")
168
+
169
+ except Exception as e:
170
+ print(f"⚠️ No se pudo calcular proporción de imagen: {e}")
171
+
172
+
173
+ # ======== Calcular alto visible ========
174
+ alto_visible = alto * (1 - crop_top - crop_bottom)
175
+
176
+ # ======== Verificar si cabe ========
177
+ if y + alto_visible > slide_height:
178
+ copiarDiaposPosicion(pres_src, pres_dst, [0])
179
+ num_slide += 1
180
+ slide = pres_dst.Slides(num_slide)
181
+ y = getNextY(slide, paddingSet) - crop_top * puntosACentimetros * 6
182
+
183
+ # ======== Convertir color HEX a RGB ========
184
+ if isinstance(color, str):
185
+ color = hex_to_rgb(color)
186
+
187
+ # ======== TEXTO / TÍTULO ========
188
+ if tipo in ["texto", "titulo"]:
189
+ textbox = slide.Shapes.AddTextbox(1, x if x != "center" else 50, y, ancho, alto)
190
+ text_range = textbox.TextFrame.TextRange
191
+ text_range.Text = contenido
192
+ text_range.Font.Name = font_name
193
+ text_range.Font.Size = font_size
194
+ text_range.Font.Bold = -1 if bold else 0
195
+ text_range.Font.Italic = -1 if italic else 0
196
+ text_range.Font.Color.RGB = color[0] + (color[1] << 8) + (color[2] << 16)
197
+
198
+ if align == "center":
199
+ textbox.TextFrame.TextRange.ParagraphFormat.Alignment = 2
200
+ elif align == "right":
201
+ textbox.TextFrame.TextRange.ParagraphFormat.Alignment = 3
202
+ elif align == "justify":
203
+ textbox.TextFrame.TextRange.ParagraphFormat.Alignment = 4
204
+ else:
205
+ textbox.TextFrame.TextRange.ParagraphFormat.Alignment = 1
206
+
207
+ if tipo == "titulo":
208
+ text_range.Font.Bold = -1
209
+
210
+ # ⭐ Aplicar rotación a texto
211
+ try:
212
+ textbox.Rotation = float(rotacion)
213
+ except:
214
+ print(f"⚠️ No se pudo aplicar rotación a texto: {rotacion}")
215
+
216
+ return textbox
217
+
218
+ # ======== IMAGEN ========
219
+ elif tipo == "imagen":
220
+ shape = slide.Shapes.AddPicture(
221
+ contenido, LinkToFile=False, SaveWithDocument=True,
222
+ Left=0, Top=y, Width=ancho, Height=alto
223
+ )
224
+
225
+ shape.LockAspectRatio = mantener_relacion
226
+
227
+ # --- Aplicar recortes ---
228
+ shape.PictureFormat.CropTop = crop_top * shape.Height
229
+ shape.PictureFormat.CropBottom = crop_bottom * shape.Height
230
+ shape.PictureFormat.CropLeft = crop_left * shape.Width
231
+ shape.PictureFormat.CropRight = crop_right * shape.Width
232
+
233
+ # --- Centrar horizontalmente ---
234
+ if isinstance(x, str) and x.lower() in ["center", "x_center"]:
235
+ ancho_real = shape.Width
236
+ shape.Left = (slide_width - ancho_real) / 2
237
+ else:
238
+ shape.Left = x
239
+
240
+ # --- ⭐ APLICAR ROTACIÓN ⭐ ---
241
+ try:
242
+ shape.Rotation = float(rotacion)
243
+ except:
244
+ print(f"⚠️ No se pudo aplicar rotación: {rotacion}")
245
+
246
+ return shape
247
+
248
+
249
+ ################################################################################################################################
250
+ ################################################################################################################################
251
+ ################################################# Agregar Elemento (shape) a una ppt ###########################################
252
+ ################################################################################################################################
253
+ ################################################################################################################################
254
+ def AgregarElementoConindicador(Indicador, tipo,df_Control,PresDest,PresSrc,contenidoAdicional):
255
+ puntosACentimetros=28.3465
256
+ if df_Control.at[Indicador, 'INSERTAR SLIDE']:
257
+ copiarDiaposPosicion(PresSrc, PresDest, [0])
258
+
259
+ if contenidoAdicional =="" and tipo != "imagen":
260
+ contenidoFinal=df_Control.at[Indicador, 'CONTENIDO']
261
+ elif contenidoAdicional !="" and tipo != "imagen":
262
+ if pd.isna(df_Control.at[Indicador, 'CONTENIDO']) or str(df_Control.at[Indicador, 'CONTENIDO']).strip().lower() in ["nan", "none", ""]:
263
+ contenidoFinal=f"{contenidoAdicional}"
264
+ else:
265
+ contenidoFinal=df_Control.at[Indicador, 'CONTENIDO'] + f" {contenidoAdicional}"
266
+
267
+ if tipo == "imagen":
268
+ agregarElemento(pres_dst=PresDest,pres_src=PresSrc,num_slide = PresDest.Slides.Count ,tipo= df_Control.at[Indicador, 'TIPO'],
269
+ contenido=os.path.abspath(df_Control.at[Indicador, 'CONTENIDO']) ,x=convertirPuntosACentimetros(df_Control.at[Indicador, 'POSICION X']),
270
+ y=convertirPuntosACentimetros(df_Control.at[Indicador, 'POSICION Y']) ,ancho=df_Control.at[Indicador, 'ANCHO'] * puntosACentimetros,
271
+ alto=df_Control.at[Indicador, 'ALTO'] * puntosACentimetros ,font_size=df_Control.at[Indicador, 'TAMANIO FUENTE'],
272
+ font_name=df_Control.at[Indicador, 'TIPO FUENTE'] ,bold=df_Control.at[Indicador, 'NEGRITA'], italic=df_Control.at[Indicador, 'CURSIVA']
273
+ ,color=df_Control.at[Indicador, 'COLOR'], align=df_Control.at[Indicador, 'ALINEACION'] ,crop_top=df_Control.at[Indicador, 'CORTE SUPERIOR']
274
+ ,crop_bottom=df_Control.at[Indicador, 'CORTE INFERIOR'] ,crop_left=df_Control.at[Indicador, 'CORTE IZQUIERDA'],
275
+ crop_right=df_Control.at[Indicador, 'CORTE DERECHA'] ,auto_crop=df_Control.at[Indicador, 'AUTO CORTE']
276
+ ,mantener_relacion=df_Control.at[Indicador, 'RELACION ASPECTO'],paddingSet=df_Control.at[Indicador, 'PADDING'],
277
+ rotacion=df_Control.at[Indicador, 'ROTACION'],mantener_relacion_base=df_Control.at[Indicador, 'BASE RELACION ASPECTO'])
278
+ else:
279
+ agregarElemento(pres_dst=PresDest,pres_src=PresSrc,num_slide = PresDest.Slides.Count ,tipo= df_Control.at[Indicador, 'TIPO'],
280
+ contenido=contenidoFinal ,x=convertirPuntosACentimetros(df_Control.at[Indicador, 'POSICION X']), y=convertirPuntosACentimetros(df_Control.at[Indicador, 'POSICION Y']),
281
+ ancho=df_Control.at[Indicador, 'ANCHO'] * puntosACentimetros, alto=df_Control.at[Indicador, 'ALTO'] * puntosACentimetros,
282
+ font_size=df_Control.at[Indicador, 'TAMANIO FUENTE'], font_name=df_Control.at[Indicador, 'TIPO FUENTE'] ,bold=df_Control.at[Indicador, 'NEGRITA'],
283
+ italic=df_Control.at[Indicador, 'CURSIVA'] ,color=df_Control.at[Indicador, 'COLOR'], align=df_Control.at[Indicador, 'ALINEACION'],
284
+ crop_top=df_Control.at[Indicador, 'CORTE SUPERIOR'], crop_bottom=df_Control.at[Indicador, 'CORTE INFERIOR'],
285
+ crop_left=df_Control.at[Indicador, 'CORTE IZQUIERDA'], crop_right=df_Control.at[Indicador, 'CORTE DERECHA'],
286
+ auto_crop=df_Control.at[Indicador, 'AUTO CORTE'], mantener_relacion=df_Control.at[Indicador, 'RELACION ASPECTO'],
287
+ paddingSet=df_Control.at[Indicador, 'PADDING'],rotacion=df_Control.at[Indicador, 'ROTACION'])
288
+
289
+ ################################################################################################################################
290
+ ################################################################################################################################
291
+ ################################################# Convierte de puntos a centimetros ############################################
292
+ ################################################################################################################################
293
+ ################################################################################################################################
294
+ def convertirPuntosACentimetros(value):
295
+ puntosACentimetros=28.3465
296
+ try:
297
+ return (float(value) * puntosACentimetros)
298
+ except (ValueError, TypeError):
299
+ return str(value)
300
+
301
+ ################################################################################################################################
302
+ ################################################################################################################################
303
+ ####################################################### Convierte de HEX a RGB #################################################
304
+ ################################################################################################################################
305
+ ################################################################################################################################
306
+ def hex_to_rgb(hex_color):
307
+ """Convierte color hex (#RRGGBB) a tupla RGB (R,G,B)"""
308
+ hex_color = hex_color.lstrip("#")
309
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
motulco/py_utils.py ADDED
@@ -0,0 +1,54 @@
1
+ import os, shutil, win32com.client, importlib
2
+ import nbformat
3
+ from nbconvert.preprocessors import ExecutePreprocessor
4
+ from pathlib import Path
5
+
6
+ ################################################################################################################################
7
+ ################################################################################################################################
8
+ ################################################# Limpia el Caché ##############################################################
9
+ ################################################################################################################################
10
+ ################################################################################################################################
11
+ def limpiarCache():
12
+ # Rutas posibles de caché
13
+ paths = [
14
+ os.path.join(os.environ["LOCALAPPDATA"], "Temp", "gen_py"),
15
+ os.path.join(os.path.dirname(win32com.__file__), "gen_py"),
16
+ ]
17
+
18
+ for path in paths:
19
+ if os.path.exists(path):
20
+ print(f"Eliminando caché: {path}")
21
+ shutil.rmtree(path, ignore_errors=True)
22
+ else:
23
+ print(f"No existe: {path}")
24
+
25
+ # Forzar limpieza interna del gencache
26
+ try:
27
+ win32com.client.gencache.is_readonly = False
28
+ win32com.client.gencache.Rebuild()
29
+ print("🔄 Caché reconstruida.")
30
+ except Exception as e:
31
+ print("⚠️ No se pudo reconstruir automáticamente:", e)
32
+
33
+ ################################################################################################################################
34
+ ################################################################################################################################
35
+ ################################################# Ejecuta un ipynb desde otro codigo ###########################################
36
+ ################################################################################################################################
37
+ ################################################################################################################################
38
+ def ejecutar_notebook(ruta_notebook):
39
+ """
40
+ Ejecuta un notebook Jupyter (.ipynb) en la ruta proporcionada.
41
+ """
42
+ try:
43
+ # Cargar el notebook
44
+ with open(ruta_notebook, 'r', encoding='utf-8') as f:
45
+ notebook = nbformat.read(f, as_version=4)
46
+
47
+ # Preprocesador para ejecutar el notebook
48
+ ep = ExecutePreprocessor(timeout=600, kernel_name='python3')
49
+
50
+ # Ejecutar el notebook
51
+ ep.preprocess(notebook, {'metadata': {'path': Path(ruta_notebook).parent}})
52
+ print(f"Notebook ejecutado correctamente: {ruta_notebook}")
53
+ except Exception as e:
54
+ print(f"Error al ejecutar {ruta_notebook}: {e}")
@@ -0,0 +1,409 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ import pandas as pd
4
+ import os
5
+ from html2image import Html2Image
6
+ from PIL import Image
7
+ from PIL import ImageChops
8
+
9
+
10
+ ################################################################################################################################
11
+ ################################################################################################################################
12
+ ########################################## Generacion de graficos de Barras y Lineas ###########################################
13
+ ################################################################################################################################
14
+ ################################################################################################################################
15
+
16
+ def graficoBarrasYLineas(meses, InfoBarras=None, InfoLineas=None,
17
+ coloresBarras=["#19BDE6", "#152C47", "#BACF00"],
18
+ titulo="", ruta_salida="grafico.png", mostrar_tabla=True,
19
+ ancho_figura=14, alto_figura=6, labelLineas="Resultado Real",
20
+ colorLinea="#A461AB", AnchoGrafica=0.8, desplazamientoLeyenda=-0.25,
21
+ BarrasPorcentaje=False, LineasPorcentaje=False,
22
+ cantidadDecimBarras="0", cantidadDecimLineas="0"):
23
+
24
+ x = np.arange(len(meses))
25
+ fig, ax1 = plt.subplots(figsize=(ancho_figura, alto_figura))
26
+
27
+ # Decidir si deben compartir eje (cuando ambos tienen mismo tipo: ambos % o ambos absolutos)
28
+ share_axis = (BarrasPorcentaje == LineasPorcentaje)
29
+
30
+ # --- METAS (barras) ---
31
+ handles1, labels1 = [], []
32
+ if InfoBarras is not None and len(InfoBarras) > 0:
33
+ width = AnchoGrafica / len(InfoBarras) if len(InfoBarras) > 0 else AnchoGrafica
34
+ for i, (meta_name, valores_raw) in enumerate(InfoBarras.items()):
35
+ # Asegurarse que sean floats
36
+ valores = [float(v) for v in valores_raw]
37
+
38
+ # Multiplicar por 100 para graficar si es porcentaje
39
+ plot_vals = [v * 100 for v in valores] if BarrasPorcentaje else valores
40
+
41
+ ax1.bar(
42
+ x + (i - len(InfoBarras) / 2) * width + width / 2,
43
+ plot_vals,
44
+ width,
45
+ label=meta_name,
46
+ color=coloresBarras[i % len(coloresBarras)]
47
+ )
48
+ handles1, labels1 = ax1.get_legend_handles_labels()
49
+
50
+ # --- RESULTADOS REALES (líneas) ---
51
+ handles2, labels2 = [], []
52
+ eje_leyenda = ax1 # por defecto
53
+
54
+ if InfoLineas is not None and len(InfoLineas) > 0:
55
+ # decidir ax2: si comparten eje -> ax2 = ax1; si no -> crear twin sólo si hace falta
56
+ if share_axis:
57
+ ax2 = ax1
58
+ else:
59
+ # si las lineas están en porcentaje pero las barras no, o viceversa, usar twin
60
+ ax2 = ax1.twinx() if LineasPorcentaje else ax1
61
+
62
+ if isinstance(InfoLineas, pd.DataFrame):
63
+ num_lineas = InfoLineas.shape[1]
64
+ labels = labelLineas if isinstance(labelLineas, list) else InfoLineas.columns.tolist()
65
+ colores = colorLinea if isinstance(colorLinea, list) else [colorLinea] * num_lineas
66
+
67
+ for i, col in enumerate(InfoLineas.columns):
68
+ vals_raw = InfoLineas[col].astype(float).values
69
+ vals = [float(v) for v in vals_raw]
70
+
71
+ # Multiplicar por 100 si es porcentaje
72
+ if LineasPorcentaje:
73
+ vals = [v * 100 for v in vals]
74
+
75
+ ax2.plot(x, vals, marker="o", linewidth=2, color=colores[i % len(colores)], label=labels[i])
76
+
77
+ # Etiquetas de los puntos
78
+ max_val = max(vals) if len(vals) > 0 else 0.0
79
+ for j, val in enumerate(vals):
80
+ ax2.text(
81
+ x[j], val + max_val * 0.03,
82
+ f"{val:.{cantidadDecimLineas}f}%" if LineasPorcentaje else f"{val:.{cantidadDecimLineas}f}",
83
+ ha="center", fontsize=10, color="white",
84
+ bbox=dict(facecolor=colores[i % len(colores)], edgecolor="none", boxstyle="round,pad=0.25")
85
+ )
86
+ eje_leyenda = ax2
87
+
88
+ else:
89
+ # Caso 1 sola lista
90
+ vals = [float(v) for v in InfoLineas]
91
+
92
+ if LineasPorcentaje:
93
+ vals = [v * 100 for v in vals]
94
+ ax2.plot(x, vals, marker="o", linewidth=2, color=colorLinea,
95
+ label=labelLineas if isinstance(labelLineas, str) else labelLineas[0])
96
+
97
+ max_val = max(vals) if len(vals) > 0 else 0.0
98
+ for i, val in enumerate(vals):
99
+ ax2.text(
100
+ x[i], val + max_val * 0.03,
101
+ f"{val:.{cantidadDecimLineas}f}%" if LineasPorcentaje else f"{val:.{cantidadDecimLineas}f}",
102
+ ha="center", fontsize=8, color="white",
103
+ bbox=dict(facecolor=colorLinea if isinstance(colorLinea, str) else colorLinea[0],
104
+ edgecolor="none", boxstyle="round,pad=0.25")
105
+ )
106
+ eje_leyenda = ax2
107
+
108
+ # Combinar leyendas
109
+ handles2, labels2 = eje_leyenda.get_legend_handles_labels()
110
+ labels_final, handles_final = [], []
111
+ for h, l in zip(handles1 + handles2, labels1 + labels2):
112
+ if l not in labels_final:
113
+ labels_final.append(l)
114
+ handles_final.append(h)
115
+
116
+ # --- TÍTULO ---
117
+ plt.title(titulo, fontsize=13, pad=25)
118
+
119
+ # --- LEYENDA ---
120
+ handles_final, labels_final = [], []
121
+
122
+ if 'handles1' in locals():
123
+ handles_final += handles1
124
+ labels_final += labels1
125
+ if 'handles2' in locals():
126
+ handles_final += handles2
127
+ labels_final += labels2
128
+
129
+ if handles_final:
130
+ plt.legend(handles_final, labels_final,
131
+ loc="upper center", bbox_to_anchor=(0.5, desplazamientoLeyenda),
132
+ ncol=4, fontsize=8, frameon=False)
133
+
134
+
135
+ # --- AJUSTAR ESCALAS ---
136
+ # Si comparten eje (ambos % o ambos absolutos) forzar mismos límites
137
+ if InfoLineas is not None:
138
+ if share_axis:
139
+ # obtener límites actuales (si ax2 es el mismo que ax1 esto es redundante pero seguro)
140
+ try:
141
+ # si existe ax2 y es distinto a ax1 (por seguridad), considerar ambos
142
+ if 'ax2' in locals() and ax2 is not ax1:
143
+ min_y = min(ax1.get_ylim()[0], ax2.get_ylim()[0])
144
+ max_y = max(ax1.get_ylim()[1], ax2.get_ylim()[1])
145
+ else:
146
+ min_y, max_y = ax1.get_ylim()
147
+ ax1.set_ylim(min_y, max_y)
148
+ if 'ax2' in locals() and ax2 is not ax1:
149
+ ax2.set_ylim(min_y, max_y)
150
+ except Exception:
151
+ pass # si falla por algún motivo, no detener el plotting
152
+
153
+ # --- QUITAR EJES ---
154
+ ax1.axis("off")
155
+ if 'ax2' in locals() and (ax2 is not ax1) and InfoLineas is not None and LineasPorcentaje:
156
+ ax2.axis("off")
157
+
158
+ # --- TABLA ---
159
+ if mostrar_tabla and (InfoBarras is not None or InfoLineas is not None):
160
+ tabla_data = []
161
+ row_labels = []
162
+
163
+ # Barras
164
+ if InfoBarras is not None:
165
+ for fila_raw in InfoBarras.values():
166
+ fila = [float(v) for v in fila_raw]
167
+ fila_formateada = [
168
+ f"{val * 100:,.{cantidadDecimBarras}f}%" if BarrasPorcentaje else f"{val:,.{cantidadDecimBarras}f}"
169
+ for val in fila
170
+ ]
171
+ tabla_data.append(fila_formateada)
172
+ row_labels += list(InfoBarras.keys())
173
+
174
+ # Líneas
175
+ if InfoLineas is not None:
176
+ if isinstance(InfoLineas, pd.DataFrame):
177
+ for i, col in enumerate(InfoLineas.columns):
178
+ vals = InfoLineas[col].astype(float).values
179
+ if LineasPorcentaje:
180
+ vals = [v * 100 for v in vals]
181
+ fila_resultado = [f"{v:,.{cantidadDecimLineas}f}%" for v in vals]
182
+ else:
183
+ fila_resultado = [f"{v:,.{cantidadDecimLineas}f}" for v in vals]
184
+ tabla_data.append(fila_resultado)
185
+ row_labels += (labelLineas if isinstance(labelLineas, list) else InfoLineas.columns.tolist())
186
+ else:
187
+ vals = [float(v) for v in InfoLineas]
188
+ if LineasPorcentaje:
189
+ vals = [v * 100 for v in vals]
190
+ fila_resultado = [f"{v:,.{cantidadDecimLineas}f}%" for v in vals]
191
+ else:
192
+ fila_resultado = [f"{v:,.{cantidadDecimLineas}f}" for v in vals]
193
+ tabla_data.append(fila_resultado)
194
+ row_labels.append(labelLineas if isinstance(labelLineas, str) else labelLineas[0])
195
+
196
+ # Crear tabla
197
+ tabla = plt.table(cellText=tabla_data, rowLabels=row_labels, colLabels=meses,
198
+ cellLoc="center", loc="bottom")
199
+ tabla.auto_set_font_size(False)
200
+ tabla.set_fontsize(10) # Mas tamaño
201
+ tabla.scale(1, 1.5) #Ancho y alto
202
+ plt.subplots_adjust(left=0.05, bottom=0.30, top=0.80)
203
+
204
+ # --- GUARDAR ---
205
+ plt.tight_layout()
206
+ plt.savefig(ruta_salida.replace(".png", ".svg"), format="svg", bbox_inches="tight")
207
+ plt.close()
208
+
209
+
210
+
211
+ ################################################################################################################################
212
+ ################################################################################################################################
213
+ ######################################################## Generacion de tablas #################################################
214
+ ################################################################################################################################
215
+ ################################################################################################################################
216
+
217
+ def generar_tabla(
218
+ df,tendenciasPorColumna=None,mostrarFlechas=True,separador_miles=False,color_encabezado="#0a4d8c",color_texto_encabezado="#ffffff",
219
+ color_fondo="#ffffff",color_borde="#cccccc",ruta_imagen=None,ajusteColumnas=0,ajusteTexto=0,anchoPrimeraColumna=160,
220
+ anchoOtrasColumnas=90,resaltarUltimaFila=True,negritaUltimaFila=True,colores_celdas=None # 🆕 NUEVO
221
+ ):
222
+ df = df.copy()
223
+
224
+ # 1) Tendencias
225
+ tendencias_calculadas = {}
226
+ if tendenciasPorColumna is not None:
227
+ for col, modo in tendenciasPorColumna.items():
228
+ if modo == "auto":
229
+ tendencias_calculadas[col] = df[col].apply(
230
+ lambda x: "subió" if x > 0 else ("bajó" if x < 0 else "mantuvo")
231
+ )
232
+ elif isinstance(modo, list):
233
+ tendencias_calculadas[col] = modo
234
+
235
+ # 2) Formato de valores
236
+ def aplicar_formato(valor, col):
237
+ if pd.isna(valor):
238
+ return ""
239
+ if isinstance(valor, bool):
240
+ return "<span style='color:green; font-weight:bold; font-size:18px;'>✅</span>" if valor else "<span style='color:red; font-weight:bold; font-size:18px;'>❌</span>"
241
+ elif mostrarFlechas and col in tendencias_calculadas:
242
+ idx = df.index[df[col] == valor][0]
243
+ tendencia = tendencias_calculadas[col][idx]
244
+ if tendencia == "subió":
245
+ return f"<span style='color:green;'>▲ {valor:.1f}%</span>"
246
+ elif tendencia == "bajó":
247
+ return f"<span style='color:red;'>▼ {valor:.1f}%</span>"
248
+ else:
249
+ return f"<span style='color:gray;'>• {valor:.1f}%</span>"
250
+ elif isinstance(valor, (int, float)) and "%" in col:
251
+ return f"{valor:.1f}%"
252
+ elif separador_miles and isinstance(valor, (int, float)):
253
+ return (
254
+ f"{valor:,.0f}".replace(",", ".")
255
+ if abs(valor) >= 1000
256
+ else (f"{valor:.1f}" if valor != int(valor) else f"{int(valor)}")
257
+ )
258
+ elif isinstance(valor, (int, float)):
259
+ return f"{valor:.1f}"
260
+ else:
261
+ return valor
262
+
263
+ for col in df.columns:
264
+ df[col] = df[col].apply(lambda x: aplicar_formato(x, col))
265
+
266
+ # 3) Estilos base
267
+ font_size_base = 14 + ajusteTexto
268
+ ultima_fila_bg = "#f2f2f2" if resaltarUltimaFila else color_fondo
269
+ ultima_fila_fw = "bold" if negritaUltimaFila else "normal"
270
+
271
+ estilos = f"""
272
+ <style>
273
+ html, body {{
274
+ margin: 0;
275
+ padding: 0;
276
+ background: transparent;
277
+ }}
278
+ .tabla-tiv {{
279
+ border-collapse: collapse;
280
+ font-family: Arial, sans-serif;
281
+ font-size: {font_size_base}px;
282
+ color: #000000;
283
+ background-color: {color_fondo};
284
+ table-layout: fixed;
285
+ margin: 0;
286
+ }}
287
+ .tabla-tiv th, .tabla-tiv td {{
288
+ border: 1px solid {color_borde};
289
+ padding: 6px;
290
+ text-align: center;
291
+ vertical-align: middle;
292
+ word-wrap: break-word;
293
+ box-sizing: border-box;
294
+ }}
295
+ .tabla-tiv th {{
296
+ background-color: {color_encabezado};
297
+ color: {color_texto_encabezado};
298
+ font-weight: bold;
299
+ }}
300
+ .tabla-tiv tr:last-child td {{
301
+ background-color: {ultima_fila_bg};
302
+ font-weight: {ultima_fila_fw};
303
+ }}
304
+ .tabla-tiv th:first-child, .tabla-tiv td:first-child {{
305
+ width: {anchoPrimeraColumna + ajusteColumnas}px;
306
+ text-align: left;
307
+ padding-left: 10px;
308
+ }}
309
+ .tabla-tiv th:not(:first-child), .tabla-tiv td:not(:first-child) {{
310
+ width: {anchoOtrasColumnas + ajusteColumnas}px;
311
+ }}
312
+ </style>
313
+ """
314
+
315
+ html_tabla = estilos + df.to_html(index=False, escape=False, classes="tabla-tiv")
316
+
317
+ # 🎨 NUEVO: Colores por celda (acepta lista o DataFrame)
318
+ if colores_celdas is not None:
319
+
320
+ if isinstance(colores_celdas, pd.DataFrame):
321
+ matriz = colores_celdas.values.tolist()
322
+ else:
323
+ matriz = colores_celdas
324
+
325
+ filas, columnas = df.shape
326
+
327
+ if len(matriz) != filas or any(len(fila) != columnas for fila in matriz):
328
+ raise ValueError("colores_celdas debe tener exactamente las mismas dimensiones que df")
329
+
330
+ estilos_celdas = "<style>\n"
331
+ for i in range(filas):
332
+ for j in range(columnas):
333
+ color = matriz[i][j]
334
+ if color:
335
+ estilos_celdas += f"""
336
+ .tabla-tiv tbody tr:nth-child({i+1}) td:nth-child({j+1}) {{
337
+ background-color: {color} !important;
338
+ }}
339
+ """
340
+ estilos_celdas += "</style>\n"
341
+
342
+ html_tabla = estilos_celdas + html_tabla
343
+
344
+ # 4) Captura imagen
345
+ if ruta_imagen:
346
+ os.makedirs(os.path.dirname(ruta_imagen), exist_ok=True)
347
+ hti = Html2Image(output_path=os.path.dirname(ruta_imagen))
348
+ hti.browser.flags = [
349
+ "--hide-scrollbars",
350
+ "--no-sandbox",
351
+ "--disable-gpu",
352
+ "--force-device-scale-factor=1"
353
+ ]
354
+ hti.screenshot(
355
+ html_str=html_tabla,
356
+ save_as=os.path.basename(ruta_imagen)
357
+ )
358
+
359
+ return html_tabla
360
+
361
+
362
+ ################################################################################################################################
363
+ ################################################################################################################################
364
+ ################################################# Recortar los bordes blancos #################################################
365
+ ################################################################################################################################
366
+ ################################################################################################################################
367
+
368
+ def recortar_espacios_blancos(ruta_entrada, ruta_salida=None):
369
+ img = Image.open(ruta_entrada)
370
+ # Convertir la imagen a modo que permita detectar el fondo
371
+ img_sin_alpha = img.convert("RGB")
372
+ # Crear una imagen en blanco del mismo tamaño
373
+ fondo = Image.new("RGB", img_sin_alpha.size, (255, 255, 255))
374
+ # Calcular diferencia
375
+ diff = Image.eval(ImageChops.difference(img_sin_alpha, fondo), lambda x: 255 if x else 0)
376
+ bbox = diff.getbbox() # Encuentra el área sin blanco
377
+ if bbox:
378
+ img_recortada = img.crop(bbox)
379
+ if ruta_salida:
380
+ img_recortada.save(ruta_salida)
381
+ return img_recortada
382
+ return img
383
+
384
+
385
+ ################################################################################################################################
386
+ ################################################################################################################################
387
+ ######################################################### Girar una imagen ####################################################
388
+ ################################################################################################################################
389
+ ################################################################################################################################
390
+
391
+ def rotar_imagen_python(ruta_imagen, grados, ruta_salida=None):
392
+ """
393
+ Rota una imagen directamente en Python usando PIL.
394
+ Retorna la ruta de la imagen rotada.
395
+ """
396
+ try:
397
+ img = Image.open(ruta_imagen)
398
+ img_rotada = img.rotate(grados, expand=True) # expand evita recortes
399
+
400
+ if ruta_salida is None:
401
+ # genera nombre temporal
402
+ ruta_salida = ruta_imagen.replace(".", f"_rotada_{grados}.")
403
+
404
+ img_rotada.save(ruta_salida)
405
+ return ruta_salida
406
+
407
+ except Exception as e:
408
+ print(f"Error rotando la imagen: {e}")
409
+ return ruta_imagen
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: motulco
3
+ Version: 0.2.0
4
+ Summary: Librería de Python para manejo de archivos y DataFrames de Daniel Ochoa
5
+ Author-email: DanielOchoa <df.ochoa202@gmail.com>
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: pandas>=2.0.0
10
+ Requires-Dist: numpy>=1.26.0
11
+ Requires-Dist: openpyxl>=3.1.0
12
+ Requires-Dist: unidecode>=1.3.6
13
+ Requires-Dist: pywin32>=306
14
+ Requires-Dist: pypdf>=4.0.0
15
+ Requires-Dist: Pillow>=10.0.0
16
+ Requires-Dist: nbformat>=5.9.0
17
+ Requires-Dist: nbconvert>=7.9.0
18
+ Requires-Dist: matplotlib>=3.8.0
19
+ Requires-Dist: html2image>=2.0.4
20
+ Dynamic: license-file
21
+
22
+ # motulco
23
+ Librería de Python para manejo de archivos y procesamiento de DataFrames.
@@ -0,0 +1,14 @@
1
+ motulco/__init__.py,sha256=zRd3Kfp3CIJxaGOA9RKaiLbbWCS5OyK8QkUip-AZMZM,205
2
+ motulco/colors_utils.py,sha256=jqGLyzBD2rDfSadbqiHxr0Q-fkjiqckmoBRJuvf0gu4,846
3
+ motulco/df_utils.py,sha256=5WE0y3h-6GmQZi4W1DaZkYzD0xySFqVSVA9S-OKG_bY,12844
4
+ motulco/doc_utils.py,sha256=ieT0tnhO1U6bY9ZjIeQBwf09iMRZWerbZ6nKLaqcx9Q,5717
5
+ motulco/excel_utils.py,sha256=7yzohXl1debV-uxKRc0HTSU33wNYL3f0yIrhMmOBmUg,7801
6
+ motulco/pdf_utils.py,sha256=21BUKGZfg23Upb94Hsn1FDehuMpX_xZ2m9060iHxxWQ,1335
7
+ motulco/ppt_utils.py,sha256=U-alkXanoxm-0sKggKhxDUv4--HbQ6SsTNyGbpL-AuQ,17986
8
+ motulco/py_utils.py,sha256=5LidHC8A0o95HSziVLqDSPme5UO3TuoiPQhy_KLEXpA,2874
9
+ motulco/shapes_utils.py,sha256=GIRUXaw6emSGD8kcmRICUBN7ZqMbiV6rQp3-cnPLzdE,18366
10
+ motulco-0.2.0.dist-info/licenses/LICENSE,sha256=Jn96Lhnfqd-Zr3dFIJhaDlIZJSk-pbfnZ6sGlp0Gv5E,12
11
+ motulco-0.2.0.dist-info/METADATA,sha256=I7CE_v-OPojUrG8vZGHKmppRezIFmNERLKjDLFEE7l8,735
12
+ motulco-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
+ motulco-0.2.0.dist-info/top_level.txt,sha256=vmdmHWbWDEBabUTgsBx7i5muUhJECnn7y8JfCMki6Gg,8
14
+ motulco-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ MIT License
@@ -0,0 +1 @@
1
+ motulco