## Versión Alfa 1.0 import os import zipfile import uuid import subprocess import difflib import io import pdfplumber import pandas as pd from pypdf import PdfWriter, PdfReader, Transformation from pdf2image import convert_from_path from pdf2docx import Converter from PIL import Image from pptx import Presentation from pptx.util import Inches from reportlab.lib.pagesizes import A4, letter from reportlab.lib import colors from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_JUSTIFY from reportlab.pdfgen import canvas from reportlab.lib.units import inch from config import TEMP_DIR class PDFEngine: # --- UTILIDADES INTERNAS --- @staticmethod def _get_output_path(filename: str) -> str: unique_name = f"{uuid.uuid4().hex[:8]}_{filename}" return os.path.join(TEMP_DIR, unique_name) def get_pdf_info(self, file_path: str) -> dict: try: reader = PdfReader(file_path) meta = reader.metadata title = meta.title if meta and meta.title else "Sin título" return {"pages": len(reader.pages), "name": os.path.basename(file_path), "title": title} except: return {"pages": 0, "name": "Error", "title": ""} def _parse_range_groups(self, range_str: str, max_pages: int) -> list: groups = [] parts = range_str.split(',') for part in parts: part = part.strip() if not part: continue current_group = [] if '-' in part: try: start, end = map(int, part.split('-')) start = max(1, start) end = min(max_pages, end) if start <= end: current_group = list(range(start - 1, end)) except ValueError: continue else: try: p = int(part) if 1 <= p <= max_pages: current_group = [p - 1] except ValueError: continue if current_group: groups.append({"label": part, "indices": current_group}) return groups # --- PREVIEW --- def generate_preview(self, f, p): try: imgs = convert_from_path(f, first_page=p, last_page=p, size=(None, 400)) if imgs: out = self._get_output_path(f"preview_pg{p}.jpg") imgs[0].save(out, "JPEG") return out except: return None def get_rotated_preview(self, f, a): if not f: return None try: imgs = convert_from_path(f, first_page=1, last_page=1, size=(None, 500)) if not imgs: return None img = imgs[0] if a != 0: img = img.rotate(-a, expand=True) out = self._get_output_path(f"rot_prev_{a}.jpg") img.save(out, "JPEG") return out except: return None def get_preview_indices_from_string(self, range_str: str, max_pages: int) -> list: key_pages = [] parts = range_str.split(',') for part in parts: part = part.strip() if '-' in part: try: s, e = map(int, part.split('-')) key_pages.extend([max(1, min(s, max_pages)), max(1, min(e, max_pages))]) except ValueError: continue else: try: p = int(part) if 1 <= p <= max_pages: key_pages.append(p) except ValueError: continue return sorted(list(set(key_pages))) # --- HERRAMIENTAS PRINCIPALES --- def merge_pdfs(self, file_paths: list, order_indices: list = None, use_numbering: bool = False) -> str: if not file_paths: raise ValueError("No hay archivos.") ordered = [] if order_indices and len(order_indices) == len(file_paths): try: ordered = [file_paths[int(i)] for i in order_indices] except: ordered = file_paths else: ordered = file_paths m = PdfWriter() for p in ordered: m.append(p) temp_out = self._get_output_path("temp_unido.pdf") with open(temp_out, "wb") as f: m.write(f) if use_numbering: final_out = self._add_page_numbers(temp_out) try: os.remove(temp_out) except: pass return final_out return temp_out def _add_page_numbers(self, file_path: str) -> str: reader = PdfReader(file_path) writer = PdfWriter() num_pages = len(reader.pages) for i, page in enumerate(reader.pages): packet = io.BytesIO() can = canvas.Canvas(packet, pagesize=letter) page_width = float(page.mediabox.width) text = f"Página {i+1} de {num_pages}" can.setFont("Helvetica", 10) can.drawCentredString(page_width / 2.0, 20, text) can.save() packet.seek(0) new_pdf = PdfReader(packet) page.merge_page(new_pdf.pages[0]) writer.add_page(page) out = self._get_output_path("unido_numerado.pdf") with open(out, "wb") as f: writer.write(f) return out def add_watermark(self, file_path: str, text: str) -> str: if not file_path or not text: raise ValueError("Falta archivo o texto.") reader = PdfReader(file_path) writer = PdfWriter() packet = io.BytesIO() can = canvas.Canvas(packet, pagesize=letter) can.setFont("Helvetica-Bold", 50) can.setFillColorRGB(0.5, 0.5, 0.5, 0.3) can.saveState() can.translate(300, 400) can.rotate(45) can.drawCentredString(0, 0, text) can.restoreState() can.save() packet.seek(0) watermark_pdf = PdfReader(packet) watermark_page = watermark_pdf.pages[0] for page in reader.pages: page.merge_page(watermark_page) writer.add_page(page) out = self._get_output_path("marca_agua.pdf") with open(out, "wb") as f: writer.write(f) return out def repair_pdf(self, file_path: str) -> str: if not file_path: raise ValueError("Falta archivo.") out = self._get_output_path("reparado.pdf") cmd = [ "gs", "-o", out, "-sDEVICE=pdfwrite", "-dPDFSETTINGS=/default", "-dInteract=N", "-dNOPAUSE", "-dQUIET", "-dBATCH", file_path ] try: subprocess.run(cmd, check=True) return out except subprocess.CalledProcessError: raise RuntimeError("Ghostscript no pudo reparar el archivo (daño severo).") except Exception as e: raise RuntimeError(f"Error sistema: {e}") def split_pdf_custom(self, file_path: str, range_str: str) -> str: if not file_path: raise ValueError("Falta archivo.") r = PdfReader(file_path) g = self._parse_range_groups(range_str, len(r.pages)) if not g: raise ValueError("Rango inválido.") gen = [] base = os.path.basename(file_path).replace(".pdf", "") for group in g: w = PdfWriter() for i in group["indices"]: w.add_page(r.pages[i]) safe = group["label"].replace(" ", "") p = self._get_output_path(f"{base}_part_{safe}.pdf") with open(p, "wb") as f: w.write(f) gen.append(p) zp = self._get_output_path(f"{base}_split.zip") with zipfile.ZipFile(zp, 'w') as z: for f in gen: z.write(f, arcname=os.path.basename(f)) return zp def reorder_pages(self, file_path: str, order_str: str) -> str: if not file_path: raise ValueError("Falta archivo.") r = PdfReader(file_path) g = self._parse_range_groups(order_str, len(r.pages)) if not g: raise ValueError("Orden inválido.") w = PdfWriter() flat = [i for group in g for i in group["indices"]] for i in flat: w.add_page(r.pages[i]) out = self._get_output_path("reordenado.pdf") with open(out, "wb") as f: w.write(f) return out def compress_pdf(self, file_path: str, power: int = 3) -> str: if not file_path: raise ValueError("Falta archivo.") q = {1: "/prepress", 3: "/ebook", 4: "/screen"} gs_set = q.get(power, "/ebook") out = self._get_output_path("comprimido.pdf") cmd = ["gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4", f"-dPDFSETTINGS={gs_set}", "-dNOPAUSE", "-dQUIET", "-dBATCH", f"-sOutputFile={out}", file_path] try: subprocess.run(cmd, check=True) return out except: raise RuntimeError("Error comprimiendo (Ghostscript).") def protect_pdf(self, file_path: str, password: str) -> str: if not file_path or not password: raise ValueError("Faltan datos.") try: r = PdfReader(file_path) w = PdfWriter() for p in r.pages: w.add_page(p) w.encrypt(password) out = self._get_output_path("protegido.pdf") with open(out, "wb") as f: w.write(f) return out except Exception as e: raise RuntimeError(f"Error: {e}") def rotate_pdf(self, file_path: str, angle: int) -> str: if not file_path: raise ValueError("Falta archivo.") try: r = PdfReader(file_path) w = PdfWriter() for p in r.pages: p.rotate(angle) w.add_page(p) out = self._get_output_path(f"rotado_{angle}.pdf") with open(out, "wb") as f: w.write(f) return out except Exception as e: raise RuntimeError(f"Error: {e}") def update_metadata(self, f, t, a, s): if not f: raise ValueError("Falta archivo.") try: r = PdfReader(f) w = PdfWriter() for p in r.pages: w.add_page(p) w.add_metadata({"/Title": t, "/Author": a, "/Subject": s, "/Producer": "OpenPDF Tools"}) out = self._get_output_path("meta.pdf") with open(out, "wb") as outf: w.write(outf) return out except Exception as e: raise RuntimeError(f"Error: {e}") def extract_text(self, f): if not f: raise ValueError("Falta archivo.") try: r = PdfReader(f) txts = [] for i, p in enumerate(r.pages): t = p.extract_text() if t: txts.append(f"--- Pág {i+1} ---\n{t}\n") out = self._get_output_path(os.path.basename(f).replace(".pdf", ".txt")) with open(out, "w", encoding="utf-8") as file: file.write("\n".join(txts)) return out except Exception as e: raise RuntimeError(f"Error: {e}") # --- COMPARACIÓN DE TEXTO PALABRA POR PALABRA --- def compare_pdfs_text(self, path_a: str, path_b: str) -> str: if not path_a or not path_b: raise ValueError("Faltan archivos.") def get_all_words(path): try: reader = PdfReader(path) text = "" for page in reader.pages: extracted = page.extract_text() if extracted: text += extracted + " " return text.split() except Exception as e: raise RuntimeError(f"Error leyendo PDF: {e}") words_a = get_all_words(path_a) words_b = get_all_words(path_b) diff = difflib.ndiff(words_a, words_b) output_path = self._get_output_path("informe_diferencias_palabras.pdf") doc = SimpleDocTemplate(output_path, pagesize=A4) styles = getSampleStyleSheet() style_body = ParagraphStyle( 'Body', parent=styles['BodyText'], alignment=TA_JUSTIFY, fontSize=11, leading=14 ) story = [] story.append(Paragraph("Informe de Comparación (Modo Palabras)", styles['Heading1'])) story.append(Spacer(1, 12)) legend = 'Leyenda: Eliminado | Añadido | Texto Común' story.append(Paragraph(legend, style_body)) story.append(Spacer(1, 12)) story.append(Paragraph(f"A: {os.path.basename(path_a)} | B: {os.path.basename(path_b)}", style_body)) story.append(Spacer(1, 12)) current_html = "" word_count = 0 for token in diff: code = token[:2] word = token[2:] safe_word = word.replace('&', '&').replace('<', '<').replace('>', '>') chunk = "" if code == '- ': chunk = f'{safe_word} ' elif code == '+ ': chunk = f'{safe_word} ' elif code == ' ': chunk = f'{safe_word} ' current_html += chunk word_count += 1 if word_count > 300 and code == ' ': story.append(Paragraph(current_html, style_body)) story.append(Spacer(1, 6)) current_html = "" word_count = 0 if current_html: story.append(Paragraph(current_html, style_body)) doc.build(story) return output_path # --- CONVERSIONES OFFICE --- def pdf_to_pptx(self, f): if not f: raise ValueError("Falta archivo.") try: imgs = convert_from_path(f, dpi=150) prs = Presentation() blank = 6 for i, img in enumerate(imgs): ip = self._get_output_path(f"slide_{i}.jpg") img.save(ip, "JPEG") slide = prs.slides.add_slide(prs.slide_layouts[blank]) slide.shapes.add_picture(ip, Inches(0), Inches(0), width=prs.slide_width) out = self._get_output_path(os.path.basename(f).replace(".pdf", ".pptx")) prs.save(out) return out except Exception as e: raise RuntimeError(f"Error PPTX: {e}") def pdf_to_word(self, f): if not f: raise ValueError("Falta archivo.") try: out = self._get_output_path(os.path.basename(f).replace(".pdf", ".docx")) cv = Converter(f) cv.convert(out, start=0, end=None) cv.close() return out except Exception as e: raise RuntimeError(f"Error Word: {e}") def pdf_to_images_zip(self, f): if not f: raise ValueError("Falta archivo.") try: imgs = convert_from_path(f, dpi=150) paths = [] base = os.path.basename(f).replace(".pdf", "") for i, img in enumerate(imgs): p = self._get_output_path(f"{base}_{i+1}.jpg") img.save(p, "JPEG") paths.append(p) zp = self._get_output_path(f"{base}_imgs.zip") with zipfile.ZipFile(zp, 'w') as z: for p in paths: z.write(p, arcname=os.path.basename(p)) return zp except: raise RuntimeError("Error imgs")