tablewise 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- motor_docling.py +80 -0
- motor_excel.py +500 -0
- preconversion.py +93 -0
- tablewise-1.1.0.dist-info/METADATA +217 -0
- tablewise-1.1.0.dist-info/RECORD +12 -0
- tablewise-1.1.0.dist-info/WHEEL +5 -0
- tablewise-1.1.0.dist-info/entry_points.txt +2 -0
- tablewise-1.1.0.dist-info/licenses/LICENSE +21 -0
- tablewise-1.1.0.dist-info/top_level.txt +6 -0
- tablewise_app.py +361 -0
- tablewise_cli.py +185 -0
- tablewise_core.py +394 -0
tablewise_cli.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI de Tablewise: un documento -> un .md con cabecera de procedencia.
|
|
3
|
+
|
|
4
|
+
Reutiliza exactamente las mismas decisiones que la app de escritorio
|
|
5
|
+
(`tablewise_core.validar_conversion` y `tablewise_core.construir_comando`), para
|
|
6
|
+
que el .md producido por la terminal y el producido por la ventana sean
|
|
7
|
+
idénticos: mismo nombre saneado, mismo motor, mismo destino y misma
|
|
8
|
+
cabecera YAML.
|
|
9
|
+
|
|
10
|
+
Motores: se invocan como subproceso con un intérprete de Python aparte
|
|
11
|
+
(el de los motores, que trae docling/openpyxl). Ese intérprete se elige,
|
|
12
|
+
por orden, con `--python`, la variable `TABLEWISE_PYTHON` o el propio
|
|
13
|
+
intérprete del CLI. MarkItDown se elige con `--markitdown`, la variable
|
|
14
|
+
`TABLEWISE_MARKITDOWN` o el `markitdown` del PATH.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import importlib.metadata
|
|
21
|
+
import importlib.util
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import tablewise_core
|
|
30
|
+
import preconversion
|
|
31
|
+
|
|
32
|
+
EXIT_OK = 0
|
|
33
|
+
EXIT_FALLO_CONVERSION = 1
|
|
34
|
+
EXIT_ENTRADA_INVALIDA = 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version() -> str:
|
|
38
|
+
"""Versión instalada, o la del pyproject si se ejecuta desde el repo.
|
|
39
|
+
|
|
40
|
+
Una sola fuente de verdad: hardcodearla aquí la dejaba derivar del
|
|
41
|
+
empaquetado en el primer cambio de versión."""
|
|
42
|
+
try:
|
|
43
|
+
return importlib.metadata.version("tablewise")
|
|
44
|
+
except importlib.metadata.PackageNotFoundError:
|
|
45
|
+
return "1.1.0"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ruta_modulo(nombre: str) -> Path:
|
|
49
|
+
"""Ruta al fichero de un motor, instalado o ejecutado desde el repo."""
|
|
50
|
+
spec = importlib.util.find_spec(nombre)
|
|
51
|
+
if spec is None or not spec.origin:
|
|
52
|
+
raise SystemExit(f"Tablewise: no encuentro el módulo {nombre}.py")
|
|
53
|
+
return Path(spec.origin)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _python_motores(argumento: str | None) -> Path:
|
|
57
|
+
elegido = argumento or os.environ.get("TABLEWISE_PYTHON")
|
|
58
|
+
if elegido:
|
|
59
|
+
return Path(elegido).expanduser()
|
|
60
|
+
return Path(sys.executable)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _markitdown(argumento: str | None) -> str:
|
|
64
|
+
elegido = argumento or os.environ.get("TABLEWISE_MARKITDOWN")
|
|
65
|
+
if elegido:
|
|
66
|
+
return str(Path(elegido).expanduser())
|
|
67
|
+
encontrado = shutil.which("markitdown")
|
|
68
|
+
if not encontrado:
|
|
69
|
+
raise SystemExit(
|
|
70
|
+
"Tablewise: no encuentro markitdown. Instálalo (`pip install markitdown[all]`), "
|
|
71
|
+
"pásalo con --markitdown o define TABLEWISE_MARKITDOWN."
|
|
72
|
+
)
|
|
73
|
+
return encontrado
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def construir_parser() -> argparse.ArgumentParser:
|
|
77
|
+
p = argparse.ArgumentParser(
|
|
78
|
+
prog="tablewise",
|
|
79
|
+
description="Convierte un documento (PDF, Word, Excel, PowerPoint) a Markdown "
|
|
80
|
+
"con cabecera de procedencia, listo para un RAG.",
|
|
81
|
+
)
|
|
82
|
+
p.add_argument("documento", help="Documento de origen")
|
|
83
|
+
p.add_argument("-o", "--salida", default="~/md-repo",
|
|
84
|
+
help="Carpeta donde guardar el .md (por defecto ~/md-repo)")
|
|
85
|
+
p.add_argument("-n", "--nombre", default="",
|
|
86
|
+
help="Nombre del .md sin extensión (por defecto, el del documento)")
|
|
87
|
+
p.add_argument("--python", default=None,
|
|
88
|
+
help="Intérprete de Python para los motores (por defecto, el del CLI)")
|
|
89
|
+
p.add_argument("--markitdown", default=None,
|
|
90
|
+
help="Ruta al ejecutable markitdown (por defecto, el del PATH)")
|
|
91
|
+
p.add_argument("--force", action="store_true",
|
|
92
|
+
help="Sobrescribir el .md si ya existe (por defecto no se pisa nada)")
|
|
93
|
+
p.add_argument("--dry-run", action="store_true",
|
|
94
|
+
help="Solo imprime el comando que se ejecutaría")
|
|
95
|
+
p.add_argument("-q", "--quiet", action="store_true", help="Sin mensajes de progreso")
|
|
96
|
+
p.add_argument("--version", action="version", version=f"tablewise {_version()}")
|
|
97
|
+
return p
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def convertir(documento: str, salida: str, nombre: str = "", *, python: str | None = None,
|
|
101
|
+
markitdown: str | None = None, dry_run: bool = False, force: bool = False,
|
|
102
|
+
quiet: bool = False, fecha: dt.date | None = None) -> Path:
|
|
103
|
+
"""Convierte un documento y devuelve la ruta del .md generado.
|
|
104
|
+
|
|
105
|
+
Lanza SystemExit(2) si la entrada no es válida o el .md ya existe y no
|
|
106
|
+
se pidió --force, y RuntimeError si el motor falla: el CLI no adivina
|
|
107
|
+
nada, no pisa trabajo previo y no deja un .md a medias.
|
|
108
|
+
"""
|
|
109
|
+
hay_soffice = preconversion.buscar_soffice() is not None
|
|
110
|
+
validacion = tablewise_core.validar_conversion(documento, nombre, salida, hay_soffice=hay_soffice)
|
|
111
|
+
if validacion.error:
|
|
112
|
+
titulo, mensaje = validacion.error
|
|
113
|
+
raise SystemExit(f"Tablewise: {titulo}: {mensaje}")
|
|
114
|
+
origen = validacion.origen
|
|
115
|
+
destino = validacion.destino
|
|
116
|
+
assert origen is not None and destino is not None
|
|
117
|
+
|
|
118
|
+
motor = tablewise_core.elegir_motor(origen)
|
|
119
|
+
repo = destino.parent
|
|
120
|
+
if destino.exists() and not force and not dry_run:
|
|
121
|
+
raise SystemExit(
|
|
122
|
+
f"Tablewise: ya existe {destino}. Usa otro --nombre, otra carpeta o --force para sobrescribirlo.")
|
|
123
|
+
|
|
124
|
+
python_motores = _python_motores(python)
|
|
125
|
+
# Con MarkItDown no se usa repo_motores (el comando es otro programa).
|
|
126
|
+
repo_motores = _ruta_modulo(motor).parent if motor else repo
|
|
127
|
+
markitdown_cmd = _markitdown(markitdown)
|
|
128
|
+
|
|
129
|
+
if dry_run:
|
|
130
|
+
cmd = tablewise_core.construir_comando(
|
|
131
|
+
origen, destino, python_motores=python_motores, repo_dir=repo_motores,
|
|
132
|
+
markitdown=markitdown_cmd,
|
|
133
|
+
)
|
|
134
|
+
print(tablewise_core.comando_shell(cmd))
|
|
135
|
+
return destino
|
|
136
|
+
|
|
137
|
+
repo.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
if not quiet:
|
|
139
|
+
print(f"Convirtiendo {origen.name} con {motor or 'markitdown'}…", file=sys.stderr)
|
|
140
|
+
|
|
141
|
+
with tempfile.TemporaryDirectory(prefix=".convirtiendo-", dir=repo) as carpeta:
|
|
142
|
+
temporal = Path(carpeta) / destino.name
|
|
143
|
+
cmd = tablewise_core.construir_comando(
|
|
144
|
+
origen, temporal, python_motores=python_motores, repo_dir=repo_motores,
|
|
145
|
+
markitdown=markitdown_cmd,
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
resultado = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
149
|
+
except OSError as e:
|
|
150
|
+
raise RuntimeError(f"no se pudo lanzar el motor ({python_motores}): {e}") from e
|
|
151
|
+
if resultado.returncode != 0 or not temporal.exists():
|
|
152
|
+
detalle = (resultado.stderr or resultado.stdout or "").strip()[:1500]
|
|
153
|
+
raise RuntimeError(f"el motor {motor or 'markitdown'} no generó {destino.name}.\n{detalle}")
|
|
154
|
+
try:
|
|
155
|
+
tablewise_core.anadir_frontmatter(temporal, origen, motor or "markitdown",
|
|
156
|
+
fecha or dt.date.today())
|
|
157
|
+
except (OSError, UnicodeError) as e:
|
|
158
|
+
if not quiet:
|
|
159
|
+
print(f"Tablewise: aviso: sin cabecera de procedencia ({e})", file=sys.stderr)
|
|
160
|
+
os.replace(temporal, destino)
|
|
161
|
+
|
|
162
|
+
if not quiet:
|
|
163
|
+
print(destino)
|
|
164
|
+
return destino
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(argv: list[str] | None = None) -> int:
|
|
168
|
+
args = construir_parser().parse_args(argv)
|
|
169
|
+
try:
|
|
170
|
+
convertir(args.documento, args.salida, args.nombre, python=args.python,
|
|
171
|
+
markitdown=args.markitdown, dry_run=args.dry_run, force=args.force,
|
|
172
|
+
quiet=args.quiet)
|
|
173
|
+
except SystemExit as e:
|
|
174
|
+
if not isinstance(e.code, int):
|
|
175
|
+
print(e.code, file=sys.stderr)
|
|
176
|
+
return EXIT_ENTRADA_INVALIDA
|
|
177
|
+
return e.code
|
|
178
|
+
except RuntimeError as e:
|
|
179
|
+
print(f"Tablewise: {e}", file=sys.stderr)
|
|
180
|
+
return EXIT_FALLO_CONVERSION
|
|
181
|
+
return EXIT_OK
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
sys.exit(main())
|
tablewise_core.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lógica pura de Tablewise -- sin Tkinter ni subprocess, para poder probarla
|
|
3
|
+
con pytest en cualquier Python (el de Homebrew puede venir sin _tkinter).
|
|
4
|
+
tablewise_app.py solo pinta la ventana y delega aquí las decisiones.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Enrutamiento por tipo de documento (piloto 23/08/2026, ver README.md
|
|
17
|
+
# "Piloto de alternativas gratis"): un Excel nativo trae la estructura de
|
|
18
|
+
# celdas combinadas accesible directamente (motor_excel, sin IA, exacto);
|
|
19
|
+
# PDF/Word/PowerPoint necesitan que algo reconstruya la tabla visualmente
|
|
20
|
+
# (motor_docling, gratis, local); el resto sigue por MarkItDown, que ya
|
|
21
|
+
# cubre bien lo que no tiene tablas complejas que reconstruir.
|
|
22
|
+
# Las plantillas (.xltx/.xltm) son el mismo formato OOXML: openpyxl las lee
|
|
23
|
+
# igual y MarkItDown no las reconoce.
|
|
24
|
+
EXTENSIONES_EXCEL = {".xlsx", ".xlsm", ".xltx", ".xltm"}
|
|
25
|
+
EXTENSIONES_DOCLING = {".pdf", ".docx", ".pptx"}
|
|
26
|
+
# Word antiguo, RTF y OpenDocument: Docling no los lee y MarkItDown
|
|
27
|
+
# tampoco (solo .docx). motor_docling los pasa antes a HTML con textutil,
|
|
28
|
+
# que viene con macOS (gratis, sin instalar nada).
|
|
29
|
+
EXTENSIONES_TEXTUTIL = {".doc", ".rtf", ".odt"}
|
|
30
|
+
|
|
31
|
+
# Office 97-2003 binario: MarkItDown no tiene conversor para .ppt y para
|
|
32
|
+
# .xls necesita xlrd, que no está instalado -- antes el diálogo los
|
|
33
|
+
# ofrecía y la conversión fallaba (hallazgo de careo, MEDIA, 04/10). Con
|
|
34
|
+
# LibreOffice instalado (gratis), preconversion.py los pasa a .xlsx (para
|
|
35
|
+
# motor_excel: conserva combinadas) y .pptx (para Docling).
|
|
36
|
+
EXTENSIONES_SOFFICE = {".xls": "motor_excel", ".ppt": "motor_docling"}
|
|
37
|
+
|
|
38
|
+
_SIN_LIBREOFFICE = {
|
|
39
|
+
".ppt": "PowerPoint 97-2003 (.ppt) necesita LibreOffice para convertirse, "
|
|
40
|
+
"y no está instalado. Instálalo (gratis, y reabre esta app) o abre el fichero en "
|
|
41
|
+
"PowerPoint o Keynote y guárdalo como .pptx.",
|
|
42
|
+
".xls": "Excel 97-2003 (.xls) necesita LibreOffice para convertirse, y "
|
|
43
|
+
"no está instalado. Instálalo (gratis, y reabre esta app) o abre el fichero en Excel "
|
|
44
|
+
"o Numbers y guárdalo como .xlsx.",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def motivo_no_soportado(origen: Path, hay_soffice: bool) -> str | None:
|
|
49
|
+
"""Mensaje para el usuario si el formato no se puede convertir con lo
|
|
50
|
+
instalado en el equipo, o None."""
|
|
51
|
+
if hay_soffice:
|
|
52
|
+
return None
|
|
53
|
+
return _SIN_LIBREOFFICE.get(Path(origen).suffix.lower())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def tipos_dialogo(hay_soffice: bool = False) -> list[tuple[str, str]]:
|
|
57
|
+
"""filetypes del diálogo "Elegir documento", derivado de las mismas
|
|
58
|
+
extensiones que enrutan: a mano se había quedado sin .xlsm (y sin las
|
|
59
|
+
plantillas), que solo se podían elegir con "Todos los ficheros".
|
|
60
|
+
.xls/.ppt solo si hay LibreOffice para convertirlos."""
|
|
61
|
+
def patrones(*exts):
|
|
62
|
+
return " ".join(f"*{e}" for e in sorted(exts))
|
|
63
|
+
|
|
64
|
+
excel = set(EXTENSIONES_EXCEL)
|
|
65
|
+
powerpoint = {".pptx"}
|
|
66
|
+
if hay_soffice:
|
|
67
|
+
excel.add(".xls")
|
|
68
|
+
powerpoint.add(".ppt")
|
|
69
|
+
word = {".docx"} | EXTENSIONES_TEXTUTIL
|
|
70
|
+
return [
|
|
71
|
+
("Documentos soportados", patrones(".pdf", *word, *excel, *powerpoint)),
|
|
72
|
+
("PDF", patrones(".pdf")),
|
|
73
|
+
("Word", patrones(*word)),
|
|
74
|
+
("Excel", patrones(*excel)),
|
|
75
|
+
("PowerPoint", patrones(*powerpoint)),
|
|
76
|
+
("Todos los ficheros", "*.*"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def elegir_motor(origen: Path) -> str | None:
|
|
81
|
+
"""Nombre del motor propio que toca según la extensión, o None si va
|
|
82
|
+
por el motor por defecto (MarkItDown)."""
|
|
83
|
+
ext = origen.suffix.lower()
|
|
84
|
+
if ext in EXTENSIONES_SOFFICE:
|
|
85
|
+
return EXTENSIONES_SOFFICE[ext]
|
|
86
|
+
if ext in EXTENSIONES_EXCEL:
|
|
87
|
+
return "motor_excel"
|
|
88
|
+
if ext in EXTENSIONES_DOCLING or ext in EXTENSIONES_TEXTUTIL:
|
|
89
|
+
return "motor_docling"
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Marcas y controles de dirección (LRM/RLM, embeddings, overrides,
|
|
94
|
+
# isolates): un RLO hace que "informe\u202efdp.exe" se VEA como
|
|
95
|
+
# "informeexe.pdf" en Finder. El resto de caracteres de formato (Cf) se
|
|
96
|
+
# respeta: el ZWJ forma parte de emojis compuestos.
|
|
97
|
+
_BIDI = set("\u200e\u200f\u061c") | {chr(c) for c in range(0x202A, 0x202F)} \
|
|
98
|
+
| {chr(c) for c in range(0x2066, 0x206A)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def es_bloqueo_office(origen: Path) -> bool:
|
|
102
|
+
"""¿Es el fichero de bloqueo "~$nombre.xlsx" que Word/Excel/PowerPoint
|
|
103
|
+
dejan junto al documento mientras está abierto? Pesa ~165 bytes, no es
|
|
104
|
+
un documento: se enrutaba al motor y fallaba con un error críptico."""
|
|
105
|
+
return Path(origen).name.startswith("~$")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# APFS/ext4 admiten 255 bytes por componente de ruta. El nombre tiene que
|
|
109
|
+
# caber con ".md", el "-N" de ruta_libre y el ".<nombre>.tmp" de
|
|
110
|
+
# escribir_atomico: 200 deja margen de sobra. Sin tope, un nombre largo
|
|
111
|
+
# (o con muchos emojis, 4 bytes cada uno) fallaba con "File name too long".
|
|
112
|
+
MAX_BYTES_NOMBRE = 200
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Caracteres que no son Cf ni espacio pero se ven en blanco: rellenos de
|
|
116
|
+
# Hangul y la celda braille vacía.
|
|
117
|
+
_RELLENOS = set("\u3164\u115f\u1160\uffa0\u2800")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _invisible(ch: str) -> bool:
|
|
121
|
+
"""¿Se ve en blanco? Espacios Unicode (NBSP, U+3000...), formato (Cf:
|
|
122
|
+
ZWSP, BOM, guion blando...), rellenos, o una marca combinante suelta."""
|
|
123
|
+
return (ch.isspace() or ch in _RELLENOS
|
|
124
|
+
or unicodedata.category(ch) in ("Cf", "Mn", "Me"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _limpia_extremos(limpio: str, quitar_md: bool) -> str:
|
|
128
|
+
"""Quita puntos e invisibles de los dos extremos y, si `quitar_md`, una
|
|
129
|
+
extensión ".md" final (la app la añade). En bucle: "informe\u00a0.md"
|
|
130
|
+
deja un NBSP final al quitar ".md", y "\u200b\u3000.informe" esconde un
|
|
131
|
+
punto inicial detrás de dos invisibles (hallazgos de careo, BAJA, 04/10)."""
|
|
132
|
+
def recorta_final(t):
|
|
133
|
+
# Sin tocar marcas combinantes: al final van unidas a su carácter
|
|
134
|
+
# (U+FE0F de "❤️", el keycap de "1️⃣"); solo se quitan al principio,
|
|
135
|
+
# donde van sueltas.
|
|
136
|
+
while t and (t[-1] == "." or (_invisible(t[-1])
|
|
137
|
+
and unicodedata.category(t[-1]) not in ("Mn", "Me"))):
|
|
138
|
+
t = t[:-1]
|
|
139
|
+
return t
|
|
140
|
+
|
|
141
|
+
# El final antes que el principio: ".md" a secas debe quedar en "", no
|
|
142
|
+
# perder su punto inicial y convertirse en un fichero llamado "md".
|
|
143
|
+
limpio = recorta_final(limpio)
|
|
144
|
+
if quitar_md and limpio.lower().endswith(".md"):
|
|
145
|
+
limpio = recorta_final(limpio[:-3])
|
|
146
|
+
while limpio and (limpio[0] == "." or _invisible(limpio[0])):
|
|
147
|
+
limpio = limpio[1:]
|
|
148
|
+
return limpio
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def nombre_seguro(nombre: str) -> str | None:
|
|
152
|
+
"""Nombre de fichero sin componentes de ruta, o None si no queda nada
|
|
153
|
+
utilizable. .name descarta separadores, ".." intermedios y rutas
|
|
154
|
+
absolutas pegadas por error -- sin esto, "../../otro" escribiría fuera
|
|
155
|
+
del repo (hallazgo de careo, ALTA, 23/08). Path("..").name es ".." --
|
|
156
|
+
se rechaza explícitamente."""
|
|
157
|
+
# Fuera los caracteres de control (Cc), separadores de línea Unicode
|
|
158
|
+
# (Zl/Zp) y controles bidi: un NUL hacía que open() lanzara
|
|
159
|
+
# ValueError, y un salto pegado desde una celda o un correo acababa
|
|
160
|
+
# dentro del nombre del fichero.
|
|
161
|
+
nombre = "".join(ch for ch in nombre
|
|
162
|
+
if unicodedata.category(ch) not in ("Cc", "Zl", "Zp")
|
|
163
|
+
and ch not in _BIDI)
|
|
164
|
+
# NFC después de filtrar (un control entre letra y tilde impedía
|
|
165
|
+
# componerlas): un nombre pegado desde Finder llega en NFD y el recorte
|
|
166
|
+
# por bytes podía separar la letra de su tilde.
|
|
167
|
+
nombre = unicodedata.normalize("NFC", nombre)
|
|
168
|
+
# "\" también separa: una ruta de Windows pegada en el nombre
|
|
169
|
+
# ("C:\docs\informe") se quedaba entera, con barras, en macOS.
|
|
170
|
+
limpio = Path(nombre.strip().replace("\\", "/")).name
|
|
171
|
+
# Sin puntos ni invisibles en los extremos: ".informe.md" queda oculto
|
|
172
|
+
# en Finder ("\u200b.informe" lo parece) y muchos indexadores RAG se
|
|
173
|
+
# saltan los ficheros punto; "." y ".." quedan en "". La app añade
|
|
174
|
+
# ".md": si el usuario ya la tecleó, "informe.md" acababa en
|
|
175
|
+
# "informe.md.md" -- se quita UNA, sin distinguir mayúsculas.
|
|
176
|
+
limpio = _limpia_extremos(limpio, quitar_md=True)
|
|
177
|
+
# Recorte por BYTES UTF-8 (no por caracteres) sin partir un carácter.
|
|
178
|
+
cortado = limpio.encode("utf-8")[:MAX_BYTES_NOMBRE].decode("utf-8", "ignore")
|
|
179
|
+
if cortado != limpio:
|
|
180
|
+
# El corte puede dejar ".md" (acabaría en ".md.md"), un espacio,
|
|
181
|
+
# una tilde o un ZWJ sueltos al final.
|
|
182
|
+
while True:
|
|
183
|
+
antes = cortado
|
|
184
|
+
cortado = _limpia_extremos(cortado, quitar_md=True)
|
|
185
|
+
if cortado == antes:
|
|
186
|
+
break
|
|
187
|
+
limpio = cortado
|
|
188
|
+
if not limpio or limpio in (".", ".."):
|
|
189
|
+
return None
|
|
190
|
+
return limpio
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def nombre_salida(tecleado: str, documento: Path) -> str | None:
|
|
194
|
+
"""Nombre del .md (sin extensión): el tecleado, o el del documento si
|
|
195
|
+
no se tecleó nada -- saneado en ambos casos. Lo usan convert() y
|
|
196
|
+
copy_cli() para que no calculen el nombre cada uno a su manera (copy_cli
|
|
197
|
+
caía al stem del documento sin sanear: ".x.pdf" daba un ".x.md"
|
|
198
|
+
oculto). Un nombre tecleado inválido da None, no se sustituye en
|
|
199
|
+
silencio por el del documento."""
|
|
200
|
+
if tecleado.strip():
|
|
201
|
+
return nombre_seguro(tecleado)
|
|
202
|
+
return nombre_seguro(Path(documento).stem)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def construir_comando(origen: Path, destino: Path, *, python_motores: Path,
|
|
206
|
+
repo_dir: Path, markitdown: str) -> list[str]:
|
|
207
|
+
"""Lista argv para convertir `origen` en `destino`. La usan tanto
|
|
208
|
+
convert() como copy_cli(), para que el comando copiado sea exactamente
|
|
209
|
+
el que ejecuta la app. Expande "~" en todas las rutas: shlex.join la
|
|
210
|
+
entrecomillaría y la shell ya no la expandiría."""
|
|
211
|
+
def ruta(p) -> str:
|
|
212
|
+
try:
|
|
213
|
+
return str(Path(p).expanduser())
|
|
214
|
+
except RuntimeError: # "~usuario" que no existe: se deja tal cual
|
|
215
|
+
return str(p)
|
|
216
|
+
|
|
217
|
+
motor = elegir_motor(Path(origen))
|
|
218
|
+
if motor:
|
|
219
|
+
return [ruta(python_motores), ruta(Path(repo_dir) / f"{motor}.py"),
|
|
220
|
+
ruta(origen), ruta(destino)]
|
|
221
|
+
return [ruta(markitdown), ruta(origen), "-o", ruta(destino)]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def comando_shell(cmd: list[str]) -> str:
|
|
225
|
+
"""argv → texto pegable en una shell. shlex.join escapa comillas,
|
|
226
|
+
backticks, $() y espacios de verdad (hallazgo de careo, MEDIA, 23/08)."""
|
|
227
|
+
return shlex.join(cmd)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Solo NUESTRA cabecera exacta: un documento de origen con su propio
|
|
231
|
+
# frontmatter que empiece por "source:" no se toca (hallazgo de careo,
|
|
232
|
+
# MEDIA, 04/10: una detección por prefijo borraba contenido legítimo).
|
|
233
|
+
_NUESTRO_FRONTMATTER = re.compile(
|
|
234
|
+
r'\A---\nsource: "(?:[^"\\\n]|\\.)*"\nengine: "(?:[^"\\\n]|\\.)*"\n'
|
|
235
|
+
r'converted: \d{4}-\d{2}-\d{2}\n---\n\n?'
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _yaml_str(texto: str) -> str:
|
|
240
|
+
r"""Escalar YAML entre comillas dobles. Escapa como \uXXXX todo lo que
|
|
241
|
+
YAML no admite tal cual o reinterpreta (controles C0/C1, DEL, NEL,
|
|
242
|
+
separadores de línea, BOM, no-caracteres): con json.dumps a secas un
|
|
243
|
+
nombre con \x85 o \x7f rompía o alteraba la cabecera."""
|
|
244
|
+
salida = []
|
|
245
|
+
for ch in texto:
|
|
246
|
+
cp = ord(ch)
|
|
247
|
+
if ch == '"':
|
|
248
|
+
salida.append('\\"')
|
|
249
|
+
elif ch == "\\":
|
|
250
|
+
salida.append("\\\\")
|
|
251
|
+
elif (cp < 0x20 or 0x7F <= cp <= 0x9F or cp in (0x2028, 0x2029, 0xFEFF)
|
|
252
|
+
or 0xD800 <= cp <= 0xDFFF or cp in (0xFFFE, 0xFFFF)):
|
|
253
|
+
salida.append(f"\\u{cp:04x}")
|
|
254
|
+
else:
|
|
255
|
+
salida.append(ch)
|
|
256
|
+
return '"' + "".join(salida) + '"'
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def con_frontmatter(md: str, origen: Path, engine: str, fecha: dt.date) -> str:
|
|
260
|
+
"""Antepone una cabecera YAML de procedencia (fichero de origen, motor,
|
|
261
|
+
fecha) para que el RAG pueda citar y filtrar por fuente. Solo el nombre
|
|
262
|
+
del fichero, nunca la ruta: la ruta local no aporta al RAG y filtraría
|
|
263
|
+
la estructura de carpetas del equipo. Si el texto ya trae una cabecera
|
|
264
|
+
nuestra, se sustituye, no se apila."""
|
|
265
|
+
# Un BOM inicial (algunos conversores lo escriben) quedaba incrustado
|
|
266
|
+
# detrás de la cabecera, y escondía una cabecera nuestra previa al
|
|
267
|
+
# regex anclado en \A: se apilaban dos.
|
|
268
|
+
md = md.lstrip("\ufeff")
|
|
269
|
+
md = _NUESTRO_FRONTMATTER.sub("", md, count=1)
|
|
270
|
+
cabecera = (
|
|
271
|
+
"---\n"
|
|
272
|
+
f"source: {_yaml_str(Path(origen).name)}\n"
|
|
273
|
+
f"engine: {_yaml_str(engine)}\n"
|
|
274
|
+
f"converted: {fecha.isoformat()}\n"
|
|
275
|
+
"---\n\n"
|
|
276
|
+
)
|
|
277
|
+
return cabecera + md
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def escribir_atomico(destino: Path, texto: str) -> None:
|
|
281
|
+
"""Escribe a un temporal del mismo directorio y lo renombra: write_text
|
|
282
|
+
directo trunca primero, y un fallo a mitad (disco lleno, surrogate
|
|
283
|
+
suelto que no codifica en UTF-8) dejaba vacío o a medias un .md que
|
|
284
|
+
ya estaba bien (hallazgos de careo, BAJA, 04/10). Crea la carpeta."""
|
|
285
|
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
temporal = destino.with_name(f".{destino.name}.tmp")
|
|
287
|
+
try:
|
|
288
|
+
temporal.write_text(texto, encoding="utf-8")
|
|
289
|
+
os.replace(temporal, destino)
|
|
290
|
+
finally:
|
|
291
|
+
temporal.unlink(missing_ok=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def anadir_frontmatter(destino: Path, origen: Path, engine: str, fecha: dt.date) -> None:
|
|
295
|
+
"""Reescribe `destino` (ya generado por el motor) con la cabecera."""
|
|
296
|
+
md = destino.read_text(encoding="utf-8")
|
|
297
|
+
escribir_atomico(destino, con_frontmatter(md, origen, engine, fecha))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def misma_ruta(a: Path, b: Path) -> bool:
|
|
301
|
+
"""¿Apuntan `a` y `b` al mismo fichero (alias, "..", symlink, o
|
|
302
|
+
mayúsculas en un disco que no las distingue)? Si destino == origen, el
|
|
303
|
+
motor machacaba el documento original con el Markdown."""
|
|
304
|
+
try:
|
|
305
|
+
return os.path.samefile(Path(a).expanduser(), Path(b).expanduser())
|
|
306
|
+
except OSError: # alguno no existe: comparar rutas resueltas
|
|
307
|
+
return Path(a).expanduser().resolve() == Path(b).expanduser().resolve()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def ruta_libre(repo: Path, nombre: str) -> Path:
|
|
311
|
+
"""repo/nombre.md, o nombre-2.md, nombre-3.md... el primero libre. Para
|
|
312
|
+
ofrecer "guardar como copia" en vez de pisar un .md existente (que
|
|
313
|
+
puede llevar retoques a mano o ya estar indexado en el RAG)."""
|
|
314
|
+
candidata = repo / f"{nombre}.md"
|
|
315
|
+
n = 2
|
|
316
|
+
while candidata.exists():
|
|
317
|
+
candidata = repo / f"{nombre}-{n}.md"
|
|
318
|
+
n += 1
|
|
319
|
+
return candidata
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _ruta_de_campo(valor) -> Path:
|
|
323
|
+
"""Ruta escrita o pegada en un campo. Se recortan los espacios de los
|
|
324
|
+
extremos (una ruta pegada suele traerlos) salvo que la ruta tal cual
|
|
325
|
+
exista: macOS admite nombres como "Informe .pdf"."""
|
|
326
|
+
def expandir(texto):
|
|
327
|
+
try:
|
|
328
|
+
return Path(texto).expanduser()
|
|
329
|
+
except RuntimeError: # "~usuario" que no existe: se deja tal cual
|
|
330
|
+
return Path(texto)
|
|
331
|
+
|
|
332
|
+
tal_cual = expandir(str(valor))
|
|
333
|
+
return tal_cual if tal_cual.exists() else expandir(str(valor).strip())
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@dataclass
|
|
337
|
+
class Validacion:
|
|
338
|
+
destino: Path | None = None
|
|
339
|
+
origen: Path | None = None # ruta ya recortada y expandida
|
|
340
|
+
nombre: str | None = None
|
|
341
|
+
error: tuple[str, str] | None = None # (título, mensaje) para el diálogo
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def validar_conversion(origen: str | Path, tecleado: str, repo: str | Path, *, hay_soffice: bool,
|
|
345
|
+
comprobar_existe: bool = True) -> Validacion:
|
|
346
|
+
"""Todas las comprobaciones previas a convertir, en un solo sitio: las
|
|
347
|
+
usan "Convertir" y "Copiar comando CLI". Antes copy_cli no aplicaba
|
|
348
|
+
ninguna y copiaba comandos que escribían encima del origen, aceptaban
|
|
349
|
+
"~$x.xlsx" o cambiaban en silencio un nombre ".." por "salida"
|
|
350
|
+
(hallazgo de careo, BAJA, 04/10). `comprobar_existe=False` para la
|
|
351
|
+
plantilla de comando que se copia sin documento elegido."""
|
|
352
|
+
# Acepta el texto tal cual de los campos: Convertir y Copiar recortaban
|
|
353
|
+
# (o no) los espacios cada uno a su manera, y "~usuario" inexistente
|
|
354
|
+
# hacía reventar expanduser() (hallazgos de careo, BAJA, 04/10).
|
|
355
|
+
origen = _ruta_de_campo(origen)
|
|
356
|
+
repo = _ruta_de_campo(repo)
|
|
357
|
+
if comprobar_existe and origen.is_dir():
|
|
358
|
+
return Validacion(error=(
|
|
359
|
+
"Error",
|
|
360
|
+
f"{origen.name} es una carpeta (o un paquete de macOS, como los "
|
|
361
|
+
"de Pages/Keynote), no un documento.",
|
|
362
|
+
))
|
|
363
|
+
if comprobar_existe and not origen.is_file():
|
|
364
|
+
return Validacion(error=("Error", "El documento no existe o no lo has seleccionado bien."))
|
|
365
|
+
if repo.exists() and not repo.is_dir():
|
|
366
|
+
return Validacion(error=(
|
|
367
|
+
"Carpeta repo inválida",
|
|
368
|
+
f"{repo} es un fichero, no una carpeta. Elige la carpeta donde guardar el .md.",
|
|
369
|
+
))
|
|
370
|
+
if es_bloqueo_office(origen):
|
|
371
|
+
return Validacion(error=(
|
|
372
|
+
"No es un documento",
|
|
373
|
+
f"{origen.name} es el fichero de bloqueo que deja Office mientras "
|
|
374
|
+
"el documento está abierto. Elige el documento original, el que "
|
|
375
|
+
"no empieza por \"~$\" (Word puede recortar sus primeras letras "
|
|
376
|
+
"en el nombre del bloqueo).",
|
|
377
|
+
))
|
|
378
|
+
motivo = motivo_no_soportado(origen, hay_soffice)
|
|
379
|
+
if motivo:
|
|
380
|
+
return Validacion(error=("Formato no soportado", motivo))
|
|
381
|
+
nombre = nombre_salida(tecleado, origen)
|
|
382
|
+
if nombre is None:
|
|
383
|
+
return Validacion(error=(
|
|
384
|
+
"Nombre inválido",
|
|
385
|
+
"El nombre del .md no puede estar vacío ni ser solo una ruta (\".\"/\"..\").",
|
|
386
|
+
))
|
|
387
|
+
destino = Path(repo) / f"{nombre}.md"
|
|
388
|
+
if misma_ruta(origen, destino):
|
|
389
|
+
return Validacion(error=(
|
|
390
|
+
"Destino igual al origen",
|
|
391
|
+
f"{destino.name} es el propio documento de origen: convertirlo "
|
|
392
|
+
"ahí lo sobrescribiría. Cambia el nombre del .md o la carpeta.",
|
|
393
|
+
))
|
|
394
|
+
return Validacion(destino=destino, nombre=nombre, origen=origen)
|