# Conversión de Formatos de Archivo con Python
Python es la plataforma ideal para construir pipelines de conversión de archivos gracias a sus bibliotecas especializadas y a su capacidad de orquestar herramientas externas como FFmpeg. Esta guía cubre las conversiones más comunes con código listo para producción.
## Conversión de imágenes con Pillow
```bash
pip install Pillow
```
```python
from PIL import Image, ImageOps, ImageFilter
import pathlib
def convertir_imagen(
entrada: str,
salida: str,
*,
max_size: tuple[int, int] | None = None,
calidad: int = 85,
strip_exif: bool = True,
) -> pathlib.Path:
"""
Convierte una imagen a cualquier formato soportado por Pillow.
Preserva transparencia al convertir a PNG; elimina el canal alfa al exportar JPEG.
"""
ruta_entrada = pathlib.Path(entrada)
ruta_salida = pathlib.Path(salida)
formato_dst = ruta_salida.suffix.lstrip('.').upper()
img = Image.open(ruta_entrada)
# Corregir orientación EXIF automáticamente
img = ImageOps.exif_transpose(img)
# Redimensionar si se especifica tamaño máximo (preserva aspecto)
if max_size:
img.thumbnail(max_size, Image.LANCZOS)
# Manejar transparencia al convertir a JPEG
if formato_dst in ('JPG', 'JPEG') and img.mode in ('RGBA', 'LA', 'P'):
fondo = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
fondo.paste(img, mask=img.split()[-1] if 'A' in img.mode else None)
img = fondo
# Convertir modo para formatos que no admiten paleta
if formato_dst == 'PNG' and img.mode == 'P':
img = img.convert('RGBA')
# Parámetros de guardado por formato
kwargs: dict = {'optimize': True}
if formato_dst in ('JPG', 'JPEG'):
kwargs['quality'] = calidad
kwargs['progressive'] = True
elif formato_dst == 'WEBP':
kwargs['quality'] = calidad
kwargs['method'] = 6 # mejor compresión, más lento
elif formato_dst == 'PNG':
kwargs['compress_level'] = 9
# Eliminar metadatos EXIF si se solicita
if strip_exif:
data = list(img.getdata())
img_limpia = Image.new(img.mode, img.size)
img_limpia.putdata(data)
img = img_limpia
img.save(ruta_salida, **kwargs)
print(f"Convertido: {ruta_entrada.name} → {ruta_salida.name} ({ruta_salida.stat().st_size/1024:.1f} KB)")
return ruta_salida
# Conversiones comunes
convertir_imagen('foto.png', 'foto.jpg', calidad=90, max_size=(1920, 1080))
convertir_imagen('logo.jpg', 'logo.webp', calidad=80)
convertir_imagen('icono.png', 'icono.avif', calidad=75) # requiere Pillow + pillow-avif-plugin
```
### Conversión batch de imágenes
```python
import pathlib
from PIL import Image, ImageOps
from concurrent.futures import ThreadPoolExecutor, as_completed
def convertir_carpeta(
carpeta_entrada: str,
carpeta_salida: str,
formato_dst: str = 'webp',
max_size: tuple[int, int] = (2000, 2000),
calidad: int = 82,
trabajadores: int = 4,
):
entrada = pathlib.Path(carpeta_entrada)
salida = pathlib.Path(carpeta_salida)
salida.mkdir(parents=True, exist_ok=True)
extensiones = {'.jpg', '.jpeg', '.png', '.tiff', '.bmp', '.gif'}
archivos = [f for f in entrada.rglob('*') if f.suffix.lower() in extensiones]
ok = errores = 0
def procesar(archivo):
destino = salida / archivo.relative_to(entrada).with_suffix(f'.{formato_dst}')
destino.parent.mkdir(parents=True, exist_ok=True)
try:
convertir_imagen(str(archivo), str(destino),
max_size=max_size, calidad=calidad)
return True, archivo.name
except Exception as e:
return False, f"{archivo.name}: {e}"
with ThreadPoolExecutor(max_workers=trabajadores) as executor:
futuros = {executor.submit(procesar, f): f for f in archivos}
for futuro in as_completed(futuros):
exito, info = futuro.result()
if exito:
ok += 1
else:
errores += 1
print(f" ERROR: {info}")
print(f"\nBatch completado: {ok} OK, {errores} errores de {len(archivos)} archivos")
convertir_carpeta('fotos_originales/', 'fotos_webp/', formato_dst='webp')
```
## Conversión de vídeo y audio con FFmpeg
```python
import subprocess
import pathlib
import json
def info_media(ruta: str) -> dict:
"""Obtiene metadatos de un archivo multimedia con ffprobe."""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-show_streams', '-show_format',
ruta,
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return json.loads(result.stdout)
def convertir_video(
entrada: str,
salida: str,
*,
codec_video: str = 'libx264',
crf: int = 23, # calidad: 18 (alta) a 28 (baja)
preset: str = 'medium', # ultrafast, fast, medium, slow, veryslow
codec_audio: str = 'aac',
bitrate_audio: str = '128k',
resolucion: str | None = None, # '1280:720' o None para mantener
fps: int | None = None,
) -> None:
cmd = ['ffmpeg', '-i', entrada, '-y'] # -y: sobreescribir sin preguntar
# Vídeo
cmd += ['-c:v', codec_video, '-crf', str(crf), '-preset', preset]
if resolucion:
cmd += ['-vf', f'scale={resolucion}']
if fps:
cmd += ['-r', str(fps)]
# Audio
cmd += ['-c:a', codec_audio, '-b:a', bitrate_audio]
cmd.append(salida)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"FFmpeg error:\n{result.stderr[-2000:]}")
info = pathlib.Path(salida).stat()
print(f"Convertido: {salida} ({info.st_size/1024**2:.1f} MB)")
# Conversiones comunes
convertir_video('video.avi', 'video.mp4', codec_video='libx264', crf=22)
convertir_video('video.mov', 'video.webm', codec_video='libvpx-vp9', crf=30)
convertir_video('video.mp4', 'video_720p.mp4', resolucion='1280:720', crf=24)
def extraer_audio(entrada: str, salida: str, formato: str = 'mp3', bitrate: str = '192k'):
"""Extrae la pista de audio de un vídeo."""
cmd = ['ffmpeg', '-i', entrada, '-vn', # -vn: sin vídeo
'-c:a', 'libmp3lame' if formato == 'mp3' else formato,
'-b:a', bitrate, '-y', salida]
subprocess.run(cmd, check=True, capture_output=True)
print(f"Audio extraído: {salida}")
extraer_audio('pelicula.mp4', 'banda_sonora.mp3', bitrate='320k')
```
### Convertir audio con FFmpeg
```python
def convertir_audio(
entrada: str,
salida: str,
bitrate: str = '192k',
sample_rate: int | None = None,
) -> None:
"""Convierte entre formatos de audio: MP3, FLAC, OGG, WAV, AAC, etc."""
cmd = ['ffmpeg', '-i', entrada, '-y']
ext = pathlib.Path(salida).suffix.lower()
if ext == '.mp3':
cmd += ['-c:a', 'libmp3lame', '-b:a', bitrate]
elif ext == '.ogg':
cmd += ['-c:a', 'libvorbis', '-q:a', '5']
elif ext == '.opus':
cmd += ['-c:a', 'libopus', '-b:a', bitrate]
elif ext == '.flac':
cmd += ['-c:a', 'flac', '-compression_level', '8']
elif ext == '.aac':
cmd += ['-c:a', 'aac', '-b:a', bitrate]
elif ext == '.wav':
cmd += ['-c:a', 'pcm_s16le']
else:
cmd += ['-c:a', 'copy']
if sample_rate:
cmd += ['-ar', str(sample_rate)]
cmd.append(salida)
subprocess.run(cmd, check=True, capture_output=True)
print(f"Audio convertido: {salida}")
convertir_audio('cancion.flac', 'cancion.mp3', bitrate='320k')
convertir_audio('podcast.mp3', 'podcast.ogg')
convertir_audio('voz.wav', 'voz.opus', bitrate='64k') # muy eficiente para voz
```
## Conversión de PDF con PyMuPDF
```bash
pip install pymupdf
```
```python
import fitz # PyMuPDF
import pathlib
def pdf_a_imagenes(pdf_ruta: str, carpeta_salida: str, dpi: int = 150) -> list[str]:
"""Exporta cada página del PDF como imagen PNG."""
salida = pathlib.Path(carpeta_salida)
salida.mkdir(parents=True, exist_ok=True)
rutas = []
doc = fitz.open(pdf_ruta)
mat = fitz.Matrix(dpi / 72, dpi / 72) # factor de escala
for n, pagina in enumerate(doc):
pix = pagina.get_pixmap(matrix=mat, alpha=False)
ruta = str(salida / f"pagina_{n+1:03d}.png")
pix.save(ruta)
rutas.append(ruta)
print(f" Página {n+1}/{len(doc)}: {pix.width}x{pix.height}px")
doc.close()
print(f"PDF exportado: {len(rutas)} páginas en {carpeta_salida}")
return rutas
def extraer_texto_pdf(pdf_ruta: str) -> str:
"""Extrae texto de todas las páginas de un PDF."""
doc = fitz.open(pdf_ruta)
texto = '\n\n'.join(pagina.get_text() for pagina in doc)
doc.close()
return texto
def imagenes_a_pdf(imagenes: list[str], salida_pdf: str) -> None:
"""Combina imágenes en un PDF de una sola página por imagen."""
doc = fitz.open()
for ruta_img in imagenes:
with fitz.open(ruta_img) as img_doc:
rect = img_doc[0].rect
pagina = doc.new_page(width=rect.width, height=rect.height)
pagina.insert_image(rect, filename=ruta_img)
doc.save(salida_pdf, garbage=4, deflate=True)
print(f"PDF creado: {salida_pdf}")
# Uso
rutas_img = pdf_a_imagenes('documento.pdf', 'paginas/', dpi=200)
texto = extraer_texto_pdf('informe.pdf')
imagenes_a_pdf(['pag1.png', 'pag2.png', 'pag3.png'], 'resultado.pdf')
```
## Conversión de documentos Word con python-docx
```bash
pip install python-docx
```
```python
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
import pathlib
def docx_a_texto(ruta_docx: str) -> str:
"""Extrae el texto completo de un archivo .docx."""
doc = Document(ruta_docx)
lineas = []
for parrafo in doc.paragraphs:
if parrafo.text.strip():
lineas.append(parrafo.text)
for tabla in doc.tables:
for fila in tabla.rows:
celdas = [c.text.strip() for c in fila.cells]
lineas.append('\t'.join(celdas))
return '\n'.join(lineas)
def crear_docx_desde_datos(datos: list[dict], salida: str) -> None:
"""Crea un .docx formateado desde una lista de registros."""
doc = Document()
# Título
titulo = doc.add_heading('Informe de Conversiones', level=1)
titulo.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Tabla de datos
if datos:
tabla = doc.add_table(rows=1, cols=len(datos[0]))
tabla.style = 'Table Grid'
# Cabecera
hdr = tabla.rows[0].cells
for i, clave in enumerate(datos[0].keys()):
hdr[i].text = clave.replace('_', ' ').title()
# Filas de datos
for registro in datos:
fila = tabla.add_row().cells
for i, valor in enumerate(registro.values()):
fila[i].text = str(valor)
doc.save(salida)
print(f"DOCX creado: {salida}")
texto = docx_a_texto('contrato.docx')
print(texto[:500])
crear_docx_desde_datos([
{'nombre': 'foto.jpg', 'formato_dst': 'WebP', 'tamanyo_kb': 45, 'ratio': '68%'},
{'nombre': 'video.avi', 'formato_dst': 'MP4', 'tamanyo_kb': 12340, 'ratio': '45%'},
], 'reporte_conversiones.docx')
```
## Pipeline de conversión completo
```python
import pathlib
import subprocess
from concurrent.futures import ProcessPoolExecutor
from PIL import Image, ImageOps
def convertir_archivo(entrada: pathlib.Path, carpeta_salida: pathlib.Path) -> dict:
"""
Pipeline unificado que detecta el tipo y aplica la conversión adecuada.
Devuelve un dict con resultado y metadatos.
"""
ext = entrada.suffix.lower()
resultado = {'entrada': str(entrada), 'ok': False, 'error': ''}
try:
if ext in {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.gif'}:
# Imágenes → WebP
salida = carpeta_salida / entrada.with_suffix('.webp').name
img = Image.open(entrada)
img = ImageOps.exif_transpose(img)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img.save(salida, 'WEBP', quality=82, method=4)
elif ext in {'.avi', '.mov', '.mkv', '.flv', '.wmv'}:
# Vídeo → MP4 H.264
salida = carpeta_salida / entrada.with_suffix('.mp4').name
subprocess.run([
'ffmpeg', '-i', str(entrada),
'-c:v', 'libx264', '-crf', '22', '-preset', 'fast',
'-c:a', 'aac', '-b:a', '128k',
'-y', str(salida),
], check=True, capture_output=True)
elif ext in {'.flac', '.wav', '.ogg', '.wma', '.aiff'}:
# Audio → MP3
salida = carpeta_salida / entrada.with_suffix('.mp3').name
subprocess.run([
'ffmpeg', '-i', str(entrada),
'-c:a', 'libmp3lame', '-b:a', '192k',
'-y', str(salida),
], check=True, capture_output=True)
else:
resultado['error'] = f"Formato no soportado: {ext}"
return resultado
tamano_original = entrada.stat().st_size
tamano_final = salida.stat().st_size
ratio = (1 - tamano_final / tamano_original) * 100
resultado.update({
'ok': True,
'salida': str(salida),
'original_kb': tamano_original // 1024,
'final_kb': tamano_final // 1024,
'ahorro': f"{ratio:.1f}%",
})
except Exception as e:
resultado['error'] = str(e)
return resultado
# Procesar carpeta completa en paralelo
def batch_convertir(carpeta_entrada: str, carpeta_salida: str):
entrada = pathlib.Path(carpeta_entrada)
salida = pathlib.Path(carpeta_salida)
salida.mkdir(parents=True, exist_ok=True)
archivos = [f for f in entrada.rglob('*') if f.is_file()]
with ProcessPoolExecutor(max_workers=4) as executor:
resultados = list(executor.map(
lambda f: convertir_archivo(f, salida), archivos
))
ok = sum(1 for r in resultados if r['ok'])
errores = [r for r in resultados if not r['ok'] and r['error']]
print(f"\n=== Batch completado: {ok}/{len(archivos)} OK ===")
for e in errores:
print(f" ERROR {e['entrada']}: {e['error']}")
return resultados
```
## Buenas prácticas
- **Verificar la instalación de FFmpeg** al inicio: `shutil.which('ffmpeg')` — si no está disponible, fallar con un mensaje claro en lugar de un error críptico más adelante.
- **`capture_output=True`** en llamadas a FFmpeg — la salida de error es muy verbosa y contamina los logs si no se captura.
- **`ImageOps.exif_transpose(img)`** antes de cualquier manipulación — corrige la orientación EXIF que muchas cámaras y móviles insertan.
- **CRF (Constant Rate Factor) en FFmpeg**: 18-22 para calidad alta, 23-28 para balance tamaño/calidad, 28-35 para máxima compresión.
- **WebP o AVIF para imágenes web** — ahorran 30-50% vs JPEG/PNG con calidad visual similar o superior.
- **`ProcessPoolExecutor`** para conversiones intensivas en CPU (imágenes, vídeo), `ThreadPoolExecutor` para I/O (lectura/escritura de archivos sin procesado).
Guía