tablewise 1.1.2__tar.gz → 1.1.3__tar.gz

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.
Files changed (43) hide show
  1. {tablewise-1.1.2 → tablewise-1.1.3}/PKG-INFO +4 -1
  2. {tablewise-1.1.2 → tablewise-1.1.3}/README.md +3 -0
  3. {tablewise-1.1.2 → tablewise-1.1.3}/pyproject.toml +1 -1
  4. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/PKG-INFO +4 -1
  5. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/SOURCES.txt +3 -0
  6. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise_app.py +6 -4
  7. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise_cli.py +35 -7
  8. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise_core.py +164 -0
  9. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_app_convertir.py +79 -1
  10. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_cli.py +40 -19
  11. tablewise-1.1.3/tests/test_cli_paridad_api.py +139 -0
  12. tablewise-1.1.3/tests/test_contenedor_ilegible.py +119 -0
  13. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_core.py +13 -1
  14. tablewise-1.1.3/tests/test_documento_cifrado.py +159 -0
  15. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_sprint_deliver.py +18 -8
  16. {tablewise-1.1.2 → tablewise-1.1.3}/LICENSE +0 -0
  17. {tablewise-1.1.2 → tablewise-1.1.3}/motor_docling.py +0 -0
  18. {tablewise-1.1.2 → tablewise-1.1.3}/motor_excel.py +0 -0
  19. {tablewise-1.1.2 → tablewise-1.1.3}/motor_office.py +0 -0
  20. {tablewise-1.1.2 → tablewise-1.1.3}/motor_propio.py +0 -0
  21. {tablewise-1.1.2 → tablewise-1.1.3}/postpro.py +0 -0
  22. {tablewise-1.1.2 → tablewise-1.1.3}/preconversion.py +0 -0
  23. {tablewise-1.1.2 → tablewise-1.1.3}/setup.cfg +0 -0
  24. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/dependency_links.txt +0 -0
  25. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/entry_points.txt +0 -0
  26. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/requires.txt +0 -0
  27. {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/top_level.txt +0 -0
  28. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_cli_motores.py +0 -0
  29. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_docs.py +0 -0
  30. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_entradas_externas.py +0 -0
  31. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_docling.py +0 -0
  32. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_docling_cli.py +0 -0
  33. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_docling_convertir.py +0 -0
  34. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_excel.py +0 -0
  35. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_office.py +0 -0
  36. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_propio.py +0 -0
  37. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_propio_escaneados.py +0 -0
  38. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_preconversion.py +0 -0
  39. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_preconversion_ejecutables_falsos.py +0 -0
  40. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_preconversion_errores.py +0 -0
  41. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_quality.py +0 -0
  42. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_red_sintaxis.py +0 -0
  43. {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_weekly_summary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablewise
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Spreadsheet-grade document to Markdown with provenance, ready for RAG and LLM ingestion
5
5
  Author: Luis Francisco Palomares Gamarra
6
6
  License: MIT License
@@ -261,6 +261,9 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
261
261
  | `tests/test_quality.py` | synthetic quality KPI: table/row metric and table-text filtering without loading the ONNX models |
262
262
  | `tests/test_motor_office.py` | light Office engine: `.docx`/`.pptx` paragraphs and tables without Docling, CLI contract |
263
263
  | `tests/test_entradas_externas.py` | `.xls` path, malformed array formulas, backslashes in the YAML header, non-UTF-8 output, BOM and CRLF |
264
+ | `tests/test_cli_paridad_api.py` | CLI and app decide like the API: unreadable file exits 2 before any engine, last-engine failure gives the cause without a traceback, MarkItDown is the last resort after any engine |
265
+ | `tests/test_documento_cifrado.py` | password-protected PDF/Office rejected before any engine with its own reason; owner-password-only PDFs still convert; no guessing without pypdf |
266
+ | `tests/test_contenedor_ilegible.py` | container check before any engine: empty file, non-zip or truncated `.xlsx`, zip without OOXML structure; CSV/XLS/PDF untouched |
264
267
  | `tests/test_preconversion_ejecutables_falsos.py` | textutil/LibreOffice boundary with fake executables |
265
268
  | `tests/test_app_convertir.py` | `App.convert()` with fake tkinter and engine: existing `.md` (cancel / numbered copy / overwrite), failing engine, non-UTF-8 output, partial writes |
266
269
  | `tests/test_motor_docling_cli.py` | `motor_docling` CLI with a fake `docling` package |
@@ -197,6 +197,9 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
197
197
  | `tests/test_quality.py` | synthetic quality KPI: table/row metric and table-text filtering without loading the ONNX models |
198
198
  | `tests/test_motor_office.py` | light Office engine: `.docx`/`.pptx` paragraphs and tables without Docling, CLI contract |
199
199
  | `tests/test_entradas_externas.py` | `.xls` path, malformed array formulas, backslashes in the YAML header, non-UTF-8 output, BOM and CRLF |
200
+ | `tests/test_cli_paridad_api.py` | CLI and app decide like the API: unreadable file exits 2 before any engine, last-engine failure gives the cause without a traceback, MarkItDown is the last resort after any engine |
201
+ | `tests/test_documento_cifrado.py` | password-protected PDF/Office rejected before any engine with its own reason; owner-password-only PDFs still convert; no guessing without pypdf |
202
+ | `tests/test_contenedor_ilegible.py` | container check before any engine: empty file, non-zip or truncated `.xlsx`, zip without OOXML structure; CSV/XLS/PDF untouched |
200
203
  | `tests/test_preconversion_ejecutables_falsos.py` | textutil/LibreOffice boundary with fake executables |
201
204
  | `tests/test_app_convertir.py` | `App.convert()` with fake tkinter and engine: existing `.md` (cancel / numbered copy / overwrite), failing engine, non-UTF-8 output, partial writes |
202
205
  | `tests/test_motor_docling_cli.py` | `motor_docling` CLI with a fake `docling` package |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tablewise"
7
- version = "1.1.2"
7
+ version = "1.1.3"
8
8
  description = "Spreadsheet-grade document to Markdown with provenance, ready for RAG and LLM ingestion"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablewise
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Spreadsheet-grade document to Markdown with provenance, ready for RAG and LLM ingestion
5
5
  Author: Luis Francisco Palomares Gamarra
6
6
  License: MIT License
@@ -261,6 +261,9 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
261
261
  | `tests/test_quality.py` | synthetic quality KPI: table/row metric and table-text filtering without loading the ONNX models |
262
262
  | `tests/test_motor_office.py` | light Office engine: `.docx`/`.pptx` paragraphs and tables without Docling, CLI contract |
263
263
  | `tests/test_entradas_externas.py` | `.xls` path, malformed array formulas, backslashes in the YAML header, non-UTF-8 output, BOM and CRLF |
264
+ | `tests/test_cli_paridad_api.py` | CLI and app decide like the API: unreadable file exits 2 before any engine, last-engine failure gives the cause without a traceback, MarkItDown is the last resort after any engine |
265
+ | `tests/test_documento_cifrado.py` | password-protected PDF/Office rejected before any engine with its own reason; owner-password-only PDFs still convert; no guessing without pypdf |
266
+ | `tests/test_contenedor_ilegible.py` | container check before any engine: empty file, non-zip or truncated `.xlsx`, zip without OOXML structure; CSV/XLS/PDF untouched |
264
267
  | `tests/test_preconversion_ejecutables_falsos.py` | textutil/LibreOffice boundary with fake executables |
265
268
  | `tests/test_app_convertir.py` | `App.convert()` with fake tkinter and engine: existing `.md` (cancel / numbered copy / overwrite), failing engine, non-UTF-8 output, partial writes |
266
269
  | `tests/test_motor_docling_cli.py` | `motor_docling` CLI with a fake `docling` package |
@@ -19,8 +19,11 @@ tablewise.egg-info/top_level.txt
19
19
  tests/test_app_convertir.py
20
20
  tests/test_cli.py
21
21
  tests/test_cli_motores.py
22
+ tests/test_cli_paridad_api.py
23
+ tests/test_contenedor_ilegible.py
22
24
  tests/test_core.py
23
25
  tests/test_docs.py
26
+ tests/test_documento_cifrado.py
24
27
  tests/test_entradas_externas.py
25
28
  tests/test_motor_docling.py
26
29
  tests/test_motor_docling_cli.py
@@ -285,12 +285,14 @@ class App:
285
285
  try:
286
286
  # Si el motor propio falla con los modelos instalados (ONNX
287
287
  # corrupto, OOM, opset incompatible), el PDF no se queda sin
288
- # convertir: segundo intento con Docling.
288
+ # convertir: segundo intento con Docling. Detrás de cualquier
289
+ # motor, MarkItDown como última red: la misma cadena que la CLI
290
+ # y la API.
289
291
  intentos = [motor]
290
292
  if motor in {"motor_propio", "motor_office"}:
291
293
  intentos.append("motor_docling")
292
- if markitdown_cmd:
293
- intentos.append("markitdown")
294
+ if motor is not None and markitdown_cmd:
295
+ intentos.append("markitdown")
294
296
  result = None
295
297
  for i, intento in enumerate(intentos):
296
298
  cmd = tablewise_core.construir_comando(
@@ -298,7 +300,7 @@ class App:
298
300
  markitdown=markitdown_cmd, forzar_motor=intento,
299
301
  )
300
302
  result = subprocess.run(cmd, capture_output=True, text=True, check=False)
301
- if result.returncode == 0 and temporal.exists():
303
+ if result.returncode == 0 and temporal.exists() and temporal.stat().st_size > 0:
302
304
  motor = None if intento == "markitdown" else intento
303
305
  if intento == "markitdown":
304
306
  motor_legible = "MarkItDown"
@@ -50,7 +50,7 @@ def _version() -> str:
50
50
  try:
51
51
  return importlib.metadata.version("tablewise")
52
52
  except importlib.metadata.PackageNotFoundError:
53
- return "1.1.2"
53
+ return "1.1.3"
54
54
 
55
55
 
56
56
  def _ruta_modulo(nombre: str) -> Path:
@@ -90,6 +90,8 @@ _EN_TITLES = {
90
90
  "Formato no soportado": "Unsupported format",
91
91
  "Nombre inválido": "Invalid name",
92
92
  "Destino igual al origen": "Output equals input",
93
+ "Documento ilegible": "Unreadable document",
94
+ "Documento protegido": "Password-protected document",
93
95
  }
94
96
  _EN_LEGACY = {
95
97
  ".ppt": "PowerPoint 97-2003 (.ppt) needs LibreOffice, which is not installed. Install it "
@@ -114,6 +116,14 @@ def _mensaje_en(titulo: str, mensaje: str, documento: str, salida: str) -> str:
114
116
  texto = _EN_LEGACY.get(origen.suffix.lower(), mensaje)
115
117
  elif titulo == "Nombre inválido":
116
118
  texto = "the output name cannot be empty or just a path (\".\" or \"..\")."
119
+ elif titulo in {"Documento ilegible", "Documento protegido"}:
120
+ try: # the English cause comes from the same check the API uses
121
+ tablewise_core.validar_contenedor(origen)
122
+ texto = mensaje
123
+ except tablewise_core.DocumentoIlegible as e:
124
+ texto = str(e)
125
+ except OSError: # the file vanished between both checks
126
+ texto = mensaje
117
127
  elif titulo == "Destino igual al origen":
118
128
  texto = "the output .md would overwrite the source document. Change --name or --output."
119
129
  else:
@@ -122,6 +132,20 @@ def _mensaje_en(titulo: str, mensaje: str, documento: str, salida: str) -> str:
122
132
  return f"Tablewise: {texto}" if etiqueta == "Error" else f"Tablewise: {etiqueta}: {texto}"
123
133
 
124
134
 
135
+ def _motivo(salida: str) -> str:
136
+ """What the user needs from a failed engine: the error, not the traceback.
137
+
138
+ The engines are separate programs and an uncaught exception prints a full
139
+ Python traceback (MarkItDown does it for an encrypted PDF). Keep only the
140
+ final exception and the lines after it."""
141
+ texto = salida.strip()
142
+ if "Traceback (most recent call last)" not in texto:
143
+ return texto[:1500]
144
+ lineas = texto.splitlines()
145
+ inicio = max(i for i, linea in enumerate(lineas) if linea and not linea[0].isspace())
146
+ return "\n".join(lineas[inicio:]).strip()[:800]
147
+
148
+
125
149
  def construir_parser() -> argparse.ArgumentParser:
126
150
  p = argparse.ArgumentParser(
127
151
  prog="tablewise",
@@ -203,13 +227,15 @@ def convertir(documento: str, salida: str, nombre: str = "", *, python: str | No
203
227
  with tempfile.TemporaryDirectory(prefix=".convirtiendo-", dir=repo) as carpeta:
204
228
  temporal = Path(carpeta) / destino.name
205
229
  # Si el motor elegido falla (ONNX corrupto, OOM, python-docx ausente),
206
- # el documento no se queda sin convertir: Docling y, como última red,
207
- # MarkItDown.
230
+ # el documento no se queda sin convertir: Docling detrás de los motores
231
+ # de PDF y Office y, detrás de cualquier motor, MarkItDown como última
232
+ # red. Es la misma cadena que la API (`server/api/converter.py`); antes
233
+ # la CLI no probaba MarkItDown tras motor_excel y la API sí.
208
234
  intentos = [motor]
209
235
  if motor in {"motor_propio", "motor_office"}:
210
236
  intentos.append("motor_docling")
211
- if markitdown_cmd:
212
- intentos.append("markitdown")
237
+ if motor is not None and markitdown_cmd:
238
+ intentos.append("markitdown")
213
239
  for i, intento in enumerate(intentos):
214
240
  if not quiet:
215
241
  print(f"Converting {origen.name} with {intento or 'markitdown'}…", file=sys.stderr)
@@ -225,12 +251,14 @@ def convertir(documento: str, salida: str, nombre: str = "", *, python: str | No
225
251
  except subprocess.TimeoutExpired as e:
226
252
  raise RuntimeError(
227
253
  f"the {intento or 'markitdown'} engine timed out after {timeout}s.") from e
228
- if resultado.returncode == 0 and temporal.exists():
254
+ # Como la API: un .md vacío no es una conversión (MarkItDown puede
255
+ # acabar bien sin texto).
256
+ if resultado.returncode == 0 and temporal.exists() and temporal.stat().st_size > 0:
229
257
  motor = None if intento == "markitdown" else intento
230
258
  break
231
259
  temporal.unlink(missing_ok=True)
232
260
  if i == len(intentos) - 1:
233
- detalle = (resultado.stderr or resultado.stdout or "").strip()[:1500]
261
+ detalle = _motivo(resultado.stderr or resultado.stdout or "")
234
262
  raise RuntimeError(f"the {intento or 'markitdown'} engine did not produce {destino.name}.\n{detalle}")
235
263
  if not quiet:
236
264
  print(f"Tablewise: warning: {intento or 'markitdown'} failed, falling back to "
@@ -167,6 +167,152 @@ def es_bloqueo_office(origen: Path) -> bool:
167
167
  return Path(origen).name.startswith("~$")
168
168
 
169
169
 
170
+ # Formatos que son un contenedor zip. Los OOXML (Excel, Word, PowerPoint)
171
+ # llevan además "[Content_Types].xml" en la raíz; el .odt es zip ODF.
172
+ EXTENSIONES_OOXML = EXTENSIONES_EXCEL | EXTENSIONES_OFFICE
173
+ EXTENSIONES_ZIP = EXTENSIONES_OOXML | {".odt"}
174
+ # Un [Content_Types].xml real pesa unos KB; 1 MB deja margen de sobra.
175
+ MAX_BYTES_MANIFIESTO = 1024 * 1024
176
+
177
+
178
+ class DocumentoIlegible(ValueError):
179
+ """El fichero no se puede leer como el formato que dice ser."""
180
+
181
+
182
+ class DocumentoCifrado(DocumentoIlegible):
183
+ """Protegido con contraseña de apertura: hay que quitarla para convertirlo.
184
+
185
+ Subclase de DocumentoIlegible para que la CLI, la app y el lote, que ya
186
+ tratan esa, no tengan por dónde dejarlo escapar."""
187
+
188
+
189
+ # Word/Excel/PowerPoint guardan un OOXML con contraseña como contenedor OLE
190
+ # (CFB, [MS-CFB]) con un flujo "EncryptedPackage", no como zip.
191
+ _FIRMA_CFB = bytes.fromhex("D0CF11E0A1B11AE1")
192
+ _FLUJO_CIFRADO = "EncryptedPackage"
193
+ _MAX_SECTORES_DIRECTORIO = 64 # un OOXML cifrado tiene ~10 entradas
194
+
195
+
196
+ def _ooxml_cifrado(origen: Path) -> bool:
197
+ """¿Es un contenedor CFB con un flujo llamado EncryptedPackage?
198
+
199
+ Lee la cabecera, la cadena de sectores del directorio (con tope) y los
200
+ nombres de sus entradas; no recorre el contenido. Un CFB que no se deja
201
+ leer así no se declara cifrado: sigue siendo "no es un zip" (ilegible)."""
202
+ def entero(datos: bytes, inicio: int, largo: int) -> int:
203
+ return int.from_bytes(datos[inicio:inicio + largo], "little")
204
+
205
+ try:
206
+ with open(origen, "rb") as f:
207
+ cabecera = f.read(512)
208
+ if len(cabecera) < 512 or cabecera[:8] != _FIRMA_CFB:
209
+ return False
210
+ desplazamiento = entero(cabecera, 30, 2)
211
+ if desplazamiento not in (9, 12): # sectores de 512 o 4096 bytes
212
+ return False
213
+ tam = 1 << desplazamiento
214
+ n_fat = min(entero(cabecera, 44, 4), 109)
215
+ # Los primeros 109 sectores de la FAT están en la cabecera; un
216
+ # directorio que necesite más (ficheros enormes) no se sigue.
217
+ fat = [entero(cabecera, 76 + 4 * i, 4) for i in range(n_fat)]
218
+
219
+ def sector(n: int) -> bytes:
220
+ f.seek((n + 1) * tam)
221
+ return f.read(tam)
222
+
223
+ def siguiente(n: int) -> int:
224
+ bloque, posicion = divmod(n, tam // 4)
225
+ if bloque >= len(fat):
226
+ return 0xFFFFFFFE # fin de cadena: no se adivina más allá
227
+ return entero(sector(fat[bloque]), posicion * 4, 4)
228
+
229
+ actual = entero(cabecera, 48, 4)
230
+ for _ in range(_MAX_SECTORES_DIRECTORIO):
231
+ if actual >= 0xFFFFFFFA: # fin de cadena o sector especial
232
+ break
233
+ datos = sector(actual)
234
+ for i in range(0, len(datos) - 127, 128):
235
+ entrada = datos[i:i + 128]
236
+ largo = entero(entrada, 64, 2) # en bytes, con el NUL final
237
+ if entrada[66] == 2 and 2 <= largo <= 64: # 2 = flujo
238
+ nombre = entrada[:largo - 2].decode("utf-16-le", "replace")
239
+ if nombre == _FLUJO_CIFRADO:
240
+ return True
241
+ actual = siguiente(actual)
242
+ except OSError:
243
+ return False
244
+ return False
245
+
246
+
247
+ def _pdf_cifrado(origen: Path) -> bool:
248
+ """¿Pide contraseña para abrirlo? Solo True cuando pypdf lo confirma.
249
+
250
+ Un PDF con solo contraseña de propietario (limita imprimir o copiar) se
251
+ abre con la contraseña vacía y no cuenta. Si pypdf no está (el paquete
252
+ de PyPI no lo exige), falta la librería de cifrado o pypdf no entiende el
253
+ fichero, no se adivina: deciden los motores."""
254
+ try:
255
+ from pypdf import PdfReader
256
+ except ImportError:
257
+ return False
258
+ try:
259
+ with open(origen, "rb") as fichero:
260
+ lector = PdfReader(fichero, strict=False)
261
+ if not lector.is_encrypted:
262
+ return False
263
+ return not lector.decrypt("") # PasswordType.NOT_DECRYPTED == 0
264
+ except Exception:
265
+ return False
266
+
267
+
268
+ def validar_contenedor(origen: Path) -> None:
269
+ """Rechaza, antes de lanzar ningún motor, un fichero vacío, uno protegido
270
+ con contraseña (DocumentoCifrado: PDF u OOXML) o un contenedor zip roto (no
271
+ es zip, truncado, o zip sin estructura OOXML).
272
+
273
+ Batería del 26/10: un .xlsx que no era zip llegó a MarkItDown como último
274
+ recurso; MarkItDown lee lo que no reconoce como texto plano y devolvió los
275
+ bytes crudos como si fueran la conversión, y la API lo cobró. Lee el
276
+ directorio central del zip y, en OOXML, solo el manifiesto
277
+ "[Content_Types].xml" con tope de MAX_BYTES_MANIFIESTO (barato y sin riesgo
278
+ de zip bomb). El resto de miembros no se descomprime: si alguno está dañado,
279
+ los motores fallan y la API reembolsa la reserva.
280
+ """
281
+ import zipfile
282
+ import zlib
283
+
284
+ origen = Path(origen)
285
+ ext = origen.suffix.lower()
286
+ if origen.stat().st_size == 0:
287
+ raise DocumentoIlegible("The file is empty (0 bytes).")
288
+ if ext == ".pdf" and _pdf_cifrado(origen):
289
+ raise DocumentoCifrado(
290
+ "The PDF is password-protected. Remove the password and try again.")
291
+ if ext not in EXTENSIONES_ZIP:
292
+ return
293
+ if ext in EXTENSIONES_OOXML and _ooxml_cifrado(origen):
294
+ raise DocumentoCifrado(
295
+ f"The {ext} file is password-protected. Remove the password and try again.")
296
+ try:
297
+ with zipfile.ZipFile(origen) as z:
298
+ nombres = set(z.namelist())
299
+ if ext in EXTENSIONES_OOXML and "[Content_Types].xml" in nombres:
300
+ # El manifiesto se lee entero (pesa unos KB) para que su CRC se
301
+ # compruebe; con tope, que un manifiesto inflado no sea una bomba.
302
+ with z.open("[Content_Types].xml") as manifiesto:
303
+ if len(manifiesto.read(MAX_BYTES_MANIFIESTO + 1)) > MAX_BYTES_MANIFIESTO:
304
+ raise DocumentoIlegible(
305
+ f"The file is not a real {ext} (oversized Office manifest).")
306
+ except DocumentoIlegible:
307
+ raise
308
+ except (zipfile.BadZipFile, OSError, ValueError, EOFError, RuntimeError,
309
+ NotImplementedError, zlib.error) as e:
310
+ raise DocumentoIlegible(
311
+ f"The file is damaged or is not a real {ext} (unreadable zip container).") from e
312
+ if ext in EXTENSIONES_OOXML and "[Content_Types].xml" not in nombres:
313
+ raise DocumentoIlegible(f"The file is not a real {ext} (missing Office structure).")
314
+
315
+
170
316
  # APFS/ext4 admiten 255 bytes por componente de ruta. El nombre tiene que
171
317
  # caber con ".md", el "-N" de ruta_libre y el ".<nombre>.tmp" de
172
318
  # escribir_atomico: 200 deja margen de sobra. Sin tope, un nombre largo
@@ -497,6 +643,24 @@ def validar_conversion(origen: str | Path, tecleado: str, repo: str | Path, *, h
497
643
  motivo = motivo_no_soportado(origen, hay_soffice)
498
644
  if motivo:
499
645
  return Validacion(error=("Formato no soportado", motivo))
646
+ if comprobar_existe:
647
+ # La misma barrera que la API pone antes de cobrar: sin ella la CLI y
648
+ # la app lanzaban motores sobre un fichero vacío o un zip roto y acababan
649
+ # enseñando la traza de MarkItDown (batería del 14/11).
650
+ try:
651
+ validar_contenedor(origen)
652
+ except DocumentoCifrado:
653
+ return Validacion(error=(
654
+ "Documento protegido",
655
+ f"{origen.name} está protegido con contraseña. Quítasela y vuelve a "
656
+ "intentarlo.",
657
+ ))
658
+ except DocumentoIlegible:
659
+ return Validacion(error=(
660
+ "Documento ilegible",
661
+ f"{origen.name} no se puede leer: está vacío, dañado o no es un "
662
+ f"{origen.suffix.lower() or 'documento'} de verdad.",
663
+ ))
500
664
  nombre = nombre_salida(tecleado, origen)
501
665
  if nombre is None:
502
666
  return Validacion(error=(
@@ -56,6 +56,15 @@ class _MessageBox(types.ModuleType):
56
56
  return [t for t, _, _ in self.llamadas]
57
57
 
58
58
 
59
+
60
+ def _ooxml(ruta, marca: str = "") -> None:
61
+ """Contenedor OOXML mínimo y legible: desde el 14/11 la CLI y la app
62
+ rechazan antes de convertir un .xlsx/.docx que no es un zip de verdad."""
63
+ import zipfile
64
+ with zipfile.ZipFile(ruta, "w") as z:
65
+ z.writestr("[Content_Types].xml", "<Types/>")
66
+ z.writestr("marca.txt", marca)
67
+
59
68
  @pytest.fixture
60
69
  def entorno(monkeypatch, tmp_path):
61
70
  """Importa la app con un tkinter falso y devuelve (modulo, App, messagebox)."""
@@ -106,7 +115,7 @@ def _motor_que_escribe(contenido: bytes = b"| hola |\n", llamadas=None):
106
115
  def documento(tmp_path):
107
116
  origen = tmp_path / "docs" / "cierre.xlsx"
108
117
  origen.parent.mkdir()
109
- origen.write_bytes(b"xlsx sintetico")
118
+ _ooxml(origen, "xlsx sintetico")
110
119
  return origen
111
120
 
112
121
 
@@ -381,3 +390,72 @@ def test_motor_propio_que_falla_reintenta_con_docling(entorno, tmp_path, monkeyp
381
390
  assert 'engine: "motor_docling"' in salida.read_text()
382
391
  assert "motor propio falló" in app.status.get()
383
392
  assert mb.tipos() == ["info"]
393
+
394
+
395
+ def test_la_app_rechaza_un_documento_ilegible_sin_lanzar_motores(entorno, tmp_path, monkeypatch):
396
+ """Misma barrera que la CLI y la API (14/11): un .xlsx que no es zip no
397
+ llega a ningún motor."""
398
+ modulo, mb = entorno
399
+ roto = tmp_path / "roto.xlsx"
400
+ roto.write_bytes(b"this is not a zip file")
401
+ llamadas = []
402
+ monkeypatch.setattr(modulo.subprocess, "run", _motor_que_escribe(llamadas=llamadas))
403
+
404
+ _app(modulo, roto, tmp_path / "repo").convert()
405
+
406
+ assert llamadas == []
407
+ assert any(c[0] == "error" and c[1][0] == "Documento ilegible" for c in mb.llamadas)
408
+ assert not (tmp_path / "repo" / "roto.md").exists()
409
+
410
+
411
+ def test_la_app_recurre_a_markitdown_si_falla_el_motor_excel(entorno, documento, tmp_path,
412
+ monkeypatch):
413
+ """Cadena de respaldo igual que la CLI y la API: MarkItDown tras cualquier motor."""
414
+ modulo, mb = entorno
415
+ monkeypatch.setattr(modulo.App, "get_markitdown_cmd", lambda self: "markitdown")
416
+ llamadas = []
417
+
418
+ def run(cmd, **kwargs):
419
+ llamadas.append(cmd)
420
+ if cmd[0] != "markitdown": # motor_excel falla sin escribir
421
+ return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="roto")
422
+ Path(cmd[cmd.index("-o") + 1]).write_bytes(b"| hoja |\n")
423
+ return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
424
+ monkeypatch.setattr(modulo.subprocess, "run", run)
425
+
426
+ _app(modulo, documento, tmp_path / "repo").convert()
427
+
428
+ assert [c[0] for c in llamadas][-1] == "markitdown"
429
+ texto = (tmp_path / "repo" / "cierre.md").read_text(encoding="utf-8")
430
+ assert 'engine: "markitdown"' in texto
431
+
432
+
433
+ def test_la_app_no_da_por_convertido_un_md_vacio(entorno, documento, tmp_path, monkeypatch):
434
+ modulo, mb = entorno
435
+ monkeypatch.setattr(modulo.App, "get_markitdown_cmd", lambda self: "markitdown")
436
+ monkeypatch.setattr(modulo.subprocess, "run", _motor_que_escribe(contenido=b""))
437
+
438
+ _app(modulo, documento, tmp_path / "repo").convert()
439
+
440
+ assert not (tmp_path / "repo" / "cierre.md").exists()
441
+ assert any(c[0] == "error" for c in mb.llamadas)
442
+
443
+
444
+ def test_la_app_rechaza_un_pdf_protegido_sin_lanzar_motores(entorno, tmp_path, monkeypatch):
445
+ pypdf = pytest.importorskip("pypdf")
446
+ import io
447
+ writer = pypdf.PdfWriter()
448
+ writer.add_blank_page(width=200, height=200)
449
+ writer.encrypt(user_password="secreta", owner_password="otra", algorithm="RC4-128")
450
+ buf = io.BytesIO()
451
+ writer.write(buf)
452
+ pdf = tmp_path / "cifrado.pdf"
453
+ pdf.write_bytes(buf.getvalue())
454
+ modulo, mb = entorno
455
+ llamadas = []
456
+ monkeypatch.setattr(modulo.subprocess, "run", _motor_que_escribe(llamadas=llamadas))
457
+
458
+ _app(modulo, pdf, tmp_path / "repo").convert()
459
+
460
+ assert llamadas == []
461
+ assert any(c[0] == "error" and c[1][0] == "Documento protegido" for c in mb.llamadas)
@@ -13,9 +13,19 @@ import pytest
13
13
  import tablewise_cli
14
14
 
15
15
 
16
+
17
+ def _ooxml(ruta, marca: str = "") -> None:
18
+ """Contenedor OOXML mínimo y legible: desde el 14/11 la CLI y la app
19
+ rechazan antes de convertir un .xlsx/.docx que no es un zip de verdad.
20
+ `marca` distingue un fichero de otro, como hacía el texto plano."""
21
+ import zipfile
22
+ with zipfile.ZipFile(ruta, "w") as z:
23
+ z.writestr("[Content_Types].xml", "<Types/>")
24
+ z.writestr("marca.txt", marca)
25
+
16
26
  def _doc(tmp_path: Path, nombre: str = "documento.xlsx") -> Path:
17
27
  ruta = tmp_path / nombre
18
- ruta.write_text("contenido de prueba", encoding="utf-8")
28
+ _ooxml(ruta, "contenido de prueba")
19
29
  return ruta
20
30
 
21
31
 
@@ -29,6 +39,17 @@ def _motor_falso(tmp_path: Path) -> Path:
29
39
  return motor
30
40
 
31
41
 
42
+
43
+ def _markitdown_mudo(tmp_path: Path) -> str:
44
+ """MarkItDown falso que falla: desde el 14/11 la CLI lo prueba como última
45
+ red tras cualquier motor (igual que la API), y un test de «todos los motores
46
+ fallan» no puede depender del markitdown real del PATH."""
47
+ import stat
48
+ ruta = tmp_path / "markitdown-mudo"
49
+ ruta.write_text(f"#!{sys.executable}\nraise SystemExit(1)\n", encoding="utf-8")
50
+ ruta.chmod(ruta.stat().st_mode | stat.S_IXUSR)
51
+ return str(ruta)
52
+
32
53
  @pytest.fixture()
33
54
  def motor(monkeypatch, tmp_path):
34
55
  falso = _motor_falso(tmp_path)
@@ -88,7 +109,7 @@ def test_motor_que_no_escribe_es_fallo(tmp_path, monkeypatch, capsys):
88
109
  monkeypatch.setattr(tablewise_cli, "_python_motores", lambda argumento: Path(sys.executable))
89
110
  # Sin este parche, en un equipo sin markitdown el CLI sale con 2 (entorno)
90
111
  # antes de llegar al fallo del motor, y el test mediría otra cosa.
91
- monkeypatch.setattr(tablewise_cli, "_markitdown", lambda argumento: "markitdown")
112
+ monkeypatch.setattr(tablewise_cli, "_markitdown", lambda argumento: _markitdown_mudo(tmp_path))
92
113
  origen = _doc(tmp_path)
93
114
 
94
115
  codigo = tablewise_cli.main([str(origen), "-o", str(tmp_path / "repo"), "-q"])
@@ -142,7 +163,7 @@ def test_el_temporal_se_limpia_tras_fallo(tmp_path, monkeypatch):
142
163
  mudo.write_text("raise SystemExit(3)\n", encoding="utf-8")
143
164
  monkeypatch.setattr(tablewise_cli, "_ruta_modulo", lambda nombre: mudo)
144
165
  monkeypatch.setattr(tablewise_cli, "_python_motores", lambda argumento: Path(sys.executable))
145
- monkeypatch.setattr(tablewise_cli, "_markitdown", lambda argumento: "markitdown")
166
+ monkeypatch.setattr(tablewise_cli, "_markitdown", lambda argumento: _markitdown_mudo(tmp_path))
146
167
  repo = tmp_path / "repo"
147
168
 
148
169
  with pytest.raises(RuntimeError):
@@ -194,8 +215,8 @@ def test_avisa_si_un_documento_no_tiene_tablas(tmp_path, motor, capsys):
194
215
  def _carpeta(tmp_path):
195
216
  entrada = tmp_path / "entrada"
196
217
  (entrada / "sub").mkdir(parents=True)
197
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
198
- (entrada / "sub" / "b.xlsx").write_text("b", encoding="utf-8")
218
+ _ooxml(entrada / "a.xlsx", "a")
219
+ _ooxml(entrada / "sub" / "b.xlsx", "b")
199
220
  return entrada
200
221
 
201
222
 
@@ -222,7 +243,7 @@ def test_lote_sin_recursive_no_entra_en_subcarpetas(tmp_path, motor):
222
243
  def test_lote_no_pisa_un_md_existente(tmp_path, motor):
223
244
  entrada = tmp_path / "entrada"
224
245
  entrada.mkdir()
225
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
246
+ _ooxml(entrada / "a.xlsx", "a")
226
247
  salida = tmp_path / "salida"
227
248
  salida.mkdir()
228
249
  previo = salida / "a.md"
@@ -239,7 +260,7 @@ def test_lote_sigue_tras_un_fallo_y_devuelve_1(tmp_path, motor, monkeypatch):
239
260
  monkeypatch.setattr(preconversion, "buscar_soffice", lambda: None)
240
261
  entrada = tmp_path / "entrada"
241
262
  entrada.mkdir()
242
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
263
+ _ooxml(entrada / "a.xlsx", "a")
243
264
  (entrada / "b.xls").write_text("b", encoding="utf-8")
244
265
  salida = tmp_path / "salida"
245
266
 
@@ -251,8 +272,8 @@ def test_lote_sigue_tras_un_fallo_y_devuelve_1(tmp_path, motor, monkeypatch):
251
272
  def test_lote_resuelve_colisiones_de_nombre(tmp_path, motor):
252
273
  entrada = tmp_path / "entrada"
253
274
  entrada.mkdir()
254
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
255
- (entrada / "a.xlsm").write_text("b", encoding="utf-8")
275
+ _ooxml(entrada / "a.xlsx", "a")
276
+ _ooxml(entrada / "a.xlsm", "b")
256
277
  salida = tmp_path / "salida"
257
278
 
258
279
  assert tablewise_cli.main([str(entrada), "-o", str(salida), "-q"]) == 0
@@ -269,7 +290,7 @@ def test_name_y_recursive_solo_valen_con_carpeta(tmp_path, motor):
269
290
  def test_lote_puede_escribir_en_la_misma_carpeta(tmp_path, motor):
270
291
  entrada = tmp_path / "entrada"
271
292
  entrada.mkdir()
272
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
293
+ _ooxml(entrada / "a.xlsx", "a")
273
294
 
274
295
  assert tablewise_cli.main([str(entrada), "-o", str(entrada), "-q"]) == 0
275
296
  assert (entrada / "a.md").is_file()
@@ -279,8 +300,8 @@ def test_lote_excluye_la_subcarpeta_de_salida(tmp_path, motor):
279
300
  entrada = tmp_path / "entrada"
280
301
  salida = entrada / "out"
281
302
  salida.mkdir(parents=True)
282
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
283
- (salida / "seed.xlsx").write_text("seed", encoding="utf-8")
303
+ _ooxml(entrada / "a.xlsx", "a")
304
+ _ooxml(salida / "seed.xlsx", "seed")
284
305
 
285
306
  assert tablewise_cli.main([str(entrada), "-o", str(salida), "--recursive", "-q"]) == 0
286
307
  assert (salida / "a.md").is_file()
@@ -292,9 +313,9 @@ def test_lote_fail_fast_para_en_el_primer_fallo(tmp_path, motor, monkeypatch):
292
313
  monkeypatch.setattr(preconversion, "buscar_soffice", lambda: None)
293
314
  entrada = tmp_path / "entrada"
294
315
  entrada.mkdir()
295
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
316
+ _ooxml(entrada / "a.xlsx", "a")
296
317
  (entrada / "b.xls").write_text("b", encoding="utf-8")
297
- (entrada / "c.xlsx").write_text("c", encoding="utf-8")
318
+ _ooxml(entrada / "c.xlsx", "c")
298
319
  salida = tmp_path / "salida"
299
320
 
300
321
  assert tablewise_cli.main([str(entrada), "-o", str(salida), "--fail-fast", "-q"]) == 1
@@ -314,8 +335,8 @@ def test_lote_dry_run_no_crea_carpetas(tmp_path, motor):
314
335
  def test_lote_force_pisa_el_md_previo_pero_no_los_duplicados(tmp_path, motor):
315
336
  entrada = tmp_path / "entrada"
316
337
  entrada.mkdir()
317
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
318
- (entrada / "a.xlsm").write_text("b", encoding="utf-8")
338
+ _ooxml(entrada / "a.xlsx", "a")
339
+ _ooxml(entrada / "a.xlsm", "b")
319
340
  salida = tmp_path / "salida"
320
341
  salida.mkdir()
321
342
  previo = salida / "a.md"
@@ -328,7 +349,7 @@ def test_lote_force_pisa_el_md_previo_pero_no_los_duplicados(tmp_path, motor):
328
349
 
329
350
  def test_documento_vacio_es_error(tmp_path, motor, monkeypatch):
330
351
  monkeypatch.chdir(tmp_path)
331
- (tmp_path / "a.xlsx").write_text("a", encoding="utf-8")
352
+ _ooxml(tmp_path / "a.xlsx", "a")
332
353
  assert tablewise_cli.main([""]) == 2
333
354
  assert not (tmp_path / "a.md").exists()
334
355
 
@@ -336,7 +357,7 @@ def test_documento_vacio_es_error(tmp_path, motor, monkeypatch):
336
357
  def test_una_carpeta_normal_con_sufijo_pages_no_es_un_paquete(tmp_path, motor):
337
358
  carpeta = tmp_path / "datos.pages"
338
359
  carpeta.mkdir()
339
- (carpeta / "a.xlsx").write_text("a", encoding="utf-8")
360
+ _ooxml(carpeta / "a.xlsx", "a")
340
361
 
341
362
  assert tablewise_cli.main([str(carpeta), "-o", str(tmp_path / "salida"), "-q"]) == 0
342
363
  assert (tmp_path / "salida" / "a.md").is_file()
@@ -347,7 +368,7 @@ def test_lote_recursive_ignora_paquetes_mac_anidados(tmp_path, motor):
347
368
  paquete = entrada / "informe.pages" / "QuickLook"
348
369
  paquete.mkdir(parents=True)
349
370
  (paquete / "Preview.pdf").write_text("interno", encoding="utf-8")
350
- (entrada / "a.xlsx").write_text("a", encoding="utf-8")
371
+ _ooxml(entrada / "a.xlsx", "a")
351
372
  salida = tmp_path / "salida"
352
373
 
353
374
  assert tablewise_cli.main([str(entrada), "-o", str(salida), "--recursive", "-q"]) == 0
@@ -0,0 +1,139 @@
1
+ """La CLI decide como la API (batería del 14/11).
2
+
3
+ Tres costuras que la batería encontró entre las dos superficies:
4
+ - un documento ilegible: la API lo rechaza antes de cobrar (422) y la CLI
5
+ llegaba a lanzar motores y acababa enseñando la traza de MarkItDown;
6
+ - cuando falla el último motor, la CLI volcaba el stderr entero (traza de
7
+ Python incluida) al usuario;
8
+ - tras `motor_excel` la API prueba MarkItDown como última red y la CLI no.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import stat
13
+ import sys
14
+ import zipfile
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ import tablewise_cli
20
+ import tablewise_core
21
+
22
+
23
+ def _xlsx(tmp_path: Path, nombre: str = "libro.xlsx") -> Path:
24
+ ruta = tmp_path / nombre
25
+ with zipfile.ZipFile(ruta, "w") as z:
26
+ z.writestr("[Content_Types].xml", "<Types/>")
27
+ z.writestr("xl/workbook.xml", "<workbook/>")
28
+ return ruta
29
+
30
+
31
+ def _script(tmp_path: Path, nombre: str, cuerpo: str) -> Path:
32
+ """Ejecutable de verdad (con shebang): construir_comando llama a
33
+ MarkItDown como programa, no como módulo de Python."""
34
+ ruta = tmp_path / nombre
35
+ ruta.write_text(f"#!{sys.executable}\n{cuerpo}", encoding="utf-8")
36
+ ruta.chmod(ruta.stat().st_mode | stat.S_IXUSR)
37
+ return ruta
38
+
39
+
40
+ ESCRIBE = ("import pathlib, sys\n"
41
+ "pathlib.Path(sys.argv[-1]).write_text('# hoja\\n\\n| a |\\n|---|\\n| 1 |\\n', "
42
+ "encoding='utf-8')\n")
43
+ PETA = ("def leer():\n"
44
+ " raise ValueError('PDFPasswordIncorrect: el documento pide clave')\n"
45
+ "leer()\n")
46
+
47
+
48
+ @pytest.fixture()
49
+ def motores(monkeypatch, tmp_path):
50
+ """Motor propio y MarkItDown falsos; cada test decide si escriben o fallan."""
51
+ estado = {"motor": ESCRIBE, "markitdown": ESCRIBE, "llamadas": []}
52
+
53
+ def ruta_modulo(nombre):
54
+ estado["llamadas"].append(nombre)
55
+ return _script(tmp_path / "bin", f"{nombre}.py", estado["motor"])
56
+
57
+ def markitdown(argumento):
58
+ return str(_script(tmp_path / "bin", "markitdown", estado["markitdown"]))
59
+
60
+ (tmp_path / "bin").mkdir()
61
+ monkeypatch.setattr(tablewise_cli, "_ruta_modulo", ruta_modulo)
62
+ monkeypatch.setattr(tablewise_cli, "_python_motores", lambda argumento: Path(sys.executable))
63
+ monkeypatch.setattr(tablewise_cli, "_markitdown", markitdown)
64
+ return estado
65
+
66
+
67
+ @pytest.mark.parametrize("nombre, contenido, pista", [
68
+ ("roto.xlsx", b"this is not a zip file", "damaged"),
69
+ ("vacio.pdf", b"", "empty"),
70
+ ])
71
+ def test_documento_ilegible_sale_con_2_sin_lanzar_motores(tmp_path, motores, capsys,
72
+ nombre, contenido, pista):
73
+ origen = tmp_path / nombre
74
+ origen.write_bytes(contenido)
75
+
76
+ codigo = tablewise_cli.main([str(origen), "-o", str(tmp_path / "repo"), "-q"])
77
+
78
+ err = capsys.readouterr().err
79
+ assert codigo == 2
80
+ assert pista in err
81
+ assert "Traceback" not in err
82
+ assert motores["llamadas"] == []
83
+ assert not (tmp_path / "repo" / f"{origen.stem}.md").exists()
84
+
85
+
86
+ def test_la_app_tambien_rechaza_el_documento_ilegible(tmp_path):
87
+ origen = tmp_path / "roto.docx"
88
+ origen.write_bytes(b"PK\x03\x04 truncado")
89
+
90
+ v = tablewise_core.validar_conversion(origen, "", tmp_path / "repo", hay_soffice=False)
91
+
92
+ assert v.error is not None
93
+ assert v.error[0] == "Documento ilegible"
94
+
95
+
96
+ def test_la_plantilla_de_comando_sin_documento_no_valida_el_contenedor(tmp_path):
97
+ # «Copiar comando CLI» sin documento elegido: no hay fichero que leer.
98
+ v = tablewise_core.validar_conversion(tmp_path / "aun-no.xlsx", "", tmp_path / "repo",
99
+ hay_soffice=False, comprobar_existe=False)
100
+ assert v.error is None
101
+
102
+
103
+ def test_fallo_final_da_el_motivo_sin_traza(tmp_path, motores, capsys):
104
+ motores["motor"] = PETA
105
+ motores["markitdown"] = PETA
106
+ origen = _xlsx(tmp_path)
107
+
108
+ codigo = tablewise_cli.main([str(origen), "-o", str(tmp_path / "repo"), "-q"])
109
+
110
+ err = capsys.readouterr().err
111
+ assert codigo == 1
112
+ assert "did not produce libro.md" in err
113
+ assert "el documento pide clave" in err
114
+ assert "Traceback" not in err
115
+ assert 'File "' not in err
116
+
117
+
118
+ def test_motor_excel_que_falla_recurre_a_markitdown_como_la_api(tmp_path, motores):
119
+ motores["motor"] = "raise SystemExit(1)\n"
120
+ origen = _xlsx(tmp_path)
121
+
122
+ destino = tablewise_cli.convertir(str(origen), str(tmp_path / "repo"), quiet=True)
123
+
124
+ assert motores["llamadas"] # se intentó primero motor_excel
125
+ texto = destino.read_text(encoding="utf-8")
126
+ assert 'engine: "markitdown"' in texto
127
+ assert "| 1 |" in texto
128
+
129
+
130
+ def test_motor_que_acaba_bien_con_md_vacio_no_cuenta_como_conversion(tmp_path, motores, capsys):
131
+ # La API exige salida no vacía; la CLI daba por bueno un .md de 0 bytes.
132
+ motores["motor"] = "import pathlib, sys\npathlib.Path(sys.argv[-1]).write_text('')\n"
133
+ motores["markitdown"] = motores["motor"]
134
+ origen = _xlsx(tmp_path)
135
+
136
+ codigo = tablewise_cli.main([str(origen), "-o", str(tmp_path / "repo"), "-q"])
137
+
138
+ assert codigo == 1
139
+ assert not (tmp_path / "repo" / "libro.md").exists()
@@ -0,0 +1,119 @@
1
+ """Integrity of the document container, checked before any engine runs.
2
+
3
+ Defect from the 26/10 battery: `11_corrupted.xlsx` (not a zip) reached
4
+ MarkItDown's last-resort fallback, which reads an unknown file as plain text
5
+ and returned its raw bytes as "Markdown". The API answered 200 and billed it.
6
+ A document whose container is broken must be rejected up front.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from tablewise_core import DocumentoIlegible, validar_contenedor
17
+
18
+
19
+ def _xlsx_valido(path: Path) -> Path:
20
+ from openpyxl import Workbook
21
+
22
+ wb = Workbook()
23
+ wb.active["A1"] = "ok"
24
+ wb.save(path)
25
+ return path
26
+
27
+
28
+ def test_xlsx_que_no_es_zip_es_ilegible(tmp_path):
29
+ p = tmp_path / "11_corrupted.xlsx"
30
+ p.write_bytes(b"this is not a zip file\n" * 8)
31
+ with pytest.raises(DocumentoIlegible):
32
+ validar_contenedor(p)
33
+
34
+
35
+ @pytest.mark.parametrize("nombre", ["vacio.pdf", "vacio.xlsx", "vacio.csv", "vacio.docx"])
36
+ def test_fichero_de_cero_bytes_es_ilegible(tmp_path, nombre):
37
+ p = tmp_path / nombre
38
+ p.write_bytes(b"")
39
+ with pytest.raises(DocumentoIlegible):
40
+ validar_contenedor(p)
41
+
42
+
43
+ def test_xlsx_truncado_es_ilegible(tmp_path):
44
+ bueno = _xlsx_valido(tmp_path / "bueno.xlsx").read_bytes()
45
+ p = tmp_path / "truncado.xlsx"
46
+ p.write_bytes(bueno[: len(bueno) // 2])
47
+ with pytest.raises(DocumentoIlegible):
48
+ validar_contenedor(p)
49
+
50
+
51
+ def test_zip_sin_content_types_no_es_ooxml(tmp_path):
52
+ buf = io.BytesIO()
53
+ with zipfile.ZipFile(buf, "w") as z:
54
+ z.writestr("hola.txt", "no soy un libro")
55
+ p = tmp_path / "falso.docx"
56
+ p.write_bytes(buf.getvalue())
57
+ with pytest.raises(DocumentoIlegible):
58
+ validar_contenedor(p)
59
+
60
+
61
+ def test_xlsx_valido_pasa(tmp_path):
62
+ validar_contenedor(_xlsx_valido(tmp_path / "bueno.xlsx"))
63
+
64
+
65
+ @pytest.mark.parametrize("nombre,contenido", [
66
+ ("datos.csv", b"a;b\n1;2\n"),
67
+ ("viejo.xls", b"\xd0\xcf\x11\xe0 not checked: legacy binary"),
68
+ ("informe.pdf", b"%PDF-1.4 the engines decide"),
69
+ ])
70
+ def test_formatos_sin_contenedor_zip_no_se_tocan(tmp_path, nombre, contenido):
71
+ p = tmp_path / nombre
72
+ p.write_bytes(contenido)
73
+ validar_contenedor(p)
74
+
75
+
76
+ def _corromper_miembro(datos: bytes, miembro: str) -> bytes:
77
+ """Daña los bytes comprimidos de un miembro sin tocar el directorio central."""
78
+ z = zipfile.ZipFile(io.BytesIO(datos))
79
+ info = z.getinfo(miembro)
80
+ inicio = info.header_offset + 30 + len(info.filename.encode()) + len(info.extra)
81
+ roto = bytearray(datos)
82
+ for k in range(inicio, inicio + info.compress_size):
83
+ roto[k] ^= 0x5A
84
+ return bytes(roto)
85
+
86
+
87
+ def test_manifiesto_ooxml_corrupto_es_ilegible(tmp_path):
88
+ """Codex R1: el directorio central intacto no basta; el manifiesto se lee
89
+ (pequeño y con tope) y su CRC se comprueba."""
90
+ bueno = _xlsx_valido(tmp_path / "bueno.xlsx").read_bytes()
91
+ p = tmp_path / "manifiesto_roto.xlsx"
92
+ p.write_bytes(_corromper_miembro(bueno, "[Content_Types].xml"))
93
+ with pytest.raises(DocumentoIlegible):
94
+ validar_contenedor(p)
95
+
96
+
97
+ def test_manifiesto_desmesurado_es_ilegible(tmp_path):
98
+ """El tope evita descomprimir un manifiesto inflado (zip bomb)."""
99
+ buf = io.BytesIO()
100
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
101
+ z.writestr("[Content_Types].xml", "<Types>" + " " * (3 * 1024 * 1024) + "</Types>")
102
+ p = tmp_path / "inflado.docx"
103
+ p.write_bytes(buf.getvalue())
104
+ with pytest.raises(DocumentoIlegible):
105
+ validar_contenedor(p)
106
+
107
+
108
+ def test_extension_en_mayusculas_tambien_se_valida(tmp_path):
109
+ p = tmp_path / "ROTO.XLSX"
110
+ p.write_bytes(b"this is not a zip file\n" * 8)
111
+ with pytest.raises(DocumentoIlegible):
112
+ validar_contenedor(p)
113
+
114
+
115
+ def test_zip_con_datos_antepuestos_sigue_siendo_valido(tmp_path):
116
+ bueno = _xlsx_valido(tmp_path / "bueno.xlsx").read_bytes()
117
+ p = tmp_path / "con_prefijo.xlsx"
118
+ p.write_bytes(b"\0" * 64 + bueno)
119
+ validar_contenedor(p)
@@ -8,6 +8,15 @@ import pytest
8
8
  from tablewise_core import analizar_calidad, elegir_motor, nombre_seguro
9
9
 
10
10
 
11
+
12
+ def _ooxml(ruta, marca: str = "") -> None:
13
+ """Contenedor OOXML mínimo y legible: desde el 14/11 la CLI y la app
14
+ rechazan antes de convertir un .xlsx/.docx que no es un zip de verdad."""
15
+ import zipfile
16
+ with zipfile.ZipFile(ruta, "w") as z:
17
+ z.writestr("[Content_Types].xml", "<Types/>")
18
+ z.writestr("marca.txt", marca)
19
+
11
20
  @pytest.mark.parametrize("nombre,esperado", [
12
21
  ("informe", "informe"),
13
22
  (" informe ", "informe"),
@@ -482,7 +491,10 @@ from tablewise_core import validar_conversion
482
491
 
483
492
  def _doc(tmp_path, nombre="cierre.xlsx"):
484
493
  ruta = tmp_path / nombre
485
- ruta.write_bytes(b"sintetico")
494
+ if ruta.suffix.lower() in {".xlsx", ".xlsm", ".docx", ".pptx"}:
495
+ _ooxml(ruta, "sintetico")
496
+ else:
497
+ ruta.write_bytes(b"sintetico")
486
498
  return ruta
487
499
 
488
500
 
@@ -0,0 +1,159 @@
1
+ """Un documento protegido con contraseña se rechaza con su propio motivo.
2
+
3
+ Batería del 14/11: `16_encrypted.pdf` llegaba a los motores y acababa en un
4
+ «conversion_failed» genérico (API) o en la excepción de MarkItDown (CLI). El
5
+ cliente no sabía que bastaba con quitar la contraseña. Ahora se detecta antes
6
+ de lanzar motores y, en la API, antes de reservar unidades.
7
+
8
+ Cuidado con el falso positivo: un PDF con solo contraseña de propietario
9
+ (limita imprimir o copiar) se abre sin clave y SÍ se convierte.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ import tablewise_cli
20
+ from tablewise_core import DocumentoCifrado, DocumentoIlegible, validar_contenedor
21
+
22
+ CFB = bytes.fromhex("D0CF11E0A1B11AE1")
23
+
24
+
25
+ def _cfb(flujos: list[str], contenido: bytes = b"") -> bytes:
26
+ """Contenedor CFB v3 mínimo y válido ([MS-CFB]): sector 0 = FAT, sector 1 =
27
+ directorio (raíz + un flujo por nombre), sector 2 = datos opcionales."""
28
+ def u32(n):
29
+ return n.to_bytes(4, "little")
30
+ FREE, END, FATSECT = 0xFFFFFFFF, 0xFFFFFFFE, 0xFFFFFFFD
31
+ cab = bytearray(512)
32
+ cab[:8] = CFB
33
+ cab[24:26] = (0x3E).to_bytes(2, "little")
34
+ cab[26:28] = (3).to_bytes(2, "little")
35
+ cab[28:30] = b"\xfe\xff"
36
+ cab[30:32] = (9).to_bytes(2, "little")
37
+ cab[32:34] = (6).to_bytes(2, "little")
38
+ cab[44:48] = u32(1) # sectores de FAT
39
+ cab[48:52] = u32(1) # primer sector del directorio
40
+ cab[56:60] = u32(4096)
41
+ cab[60:64] = u32(END)
42
+ cab[68:72] = u32(END)
43
+ cab[76:80] = u32(0) # DIFAT[0] = sector 0
44
+ for i in range(1, 109):
45
+ cab[76 + 4 * i:80 + 4 * i] = u32(FREE)
46
+ fat = bytearray(u32(FATSECT) + u32(END) + u32(END if contenido else FREE))
47
+ fat += u32(FREE) * (128 - 3)
48
+
49
+ def entrada(nombre: str, tipo: int) -> bytes:
50
+ e = bytearray(128)
51
+ bruto = nombre.encode("utf-16-le") + b"\0\0"
52
+ e[:len(bruto)] = bruto
53
+ e[64:66] = len(bruto).to_bytes(2, "little")
54
+ e[66] = tipo
55
+ return bytes(e)
56
+ directorio = entrada("Root Entry", 5) + b"".join(entrada(n, 2) for n in flujos)
57
+ directorio = directorio.ljust(512, b"\0")[:512]
58
+ return bytes(cab) + bytes(fat) + directorio + contenido.ljust(512, b"\0")[:512] * bool(contenido)
59
+
60
+
61
+ def _pdf(tmp_path: Path, nombre: str = "doc.pdf", *, usuario: str | None = None,
62
+ propietario: str = "propietario", algoritmo: str = "RC4-128") -> Path:
63
+ pypdf = pytest.importorskip("pypdf")
64
+ writer = pypdf.PdfWriter()
65
+ writer.add_blank_page(width=200, height=200)
66
+ if usuario is not None:
67
+ writer.encrypt(user_password=usuario, owner_password=propietario, algorithm=algoritmo)
68
+ buf = io.BytesIO()
69
+ writer.write(buf)
70
+ ruta = tmp_path / nombre
71
+ ruta.write_bytes(buf.getvalue())
72
+ return ruta
73
+
74
+
75
+ @pytest.mark.parametrize("algoritmo", ["RC4-128", "AES-256"])
76
+ def test_pdf_con_contrasena_de_apertura_es_cifrado(tmp_path, algoritmo):
77
+ origen = _pdf(tmp_path, usuario="secreta", algoritmo=algoritmo)
78
+ with pytest.raises(DocumentoCifrado, match="password"):
79
+ validar_contenedor(origen)
80
+
81
+
82
+ def test_cifrado_es_un_caso_de_ilegible(tmp_path):
83
+ # La CLI, la app y el lote ya tratan DocumentoIlegible: no hay ruta nueva
84
+ # por la que se escape.
85
+ assert issubclass(DocumentoCifrado, DocumentoIlegible)
86
+
87
+
88
+ def test_pdf_con_solo_contrasena_de_propietario_se_convierte(tmp_path):
89
+ origen = _pdf(tmp_path, usuario="", propietario="solo-permisos")
90
+ import pypdf
91
+ assert pypdf.PdfReader(origen).is_encrypted # de verdad está cifrado
92
+ validar_contenedor(origen) # y aun así se abre sin clave: no se rechaza
93
+
94
+
95
+ def test_pdf_normal_pasa(tmp_path):
96
+ validar_contenedor(_pdf(tmp_path))
97
+
98
+
99
+ def test_pdf_que_pypdf_no_entiende_lo_deciden_los_motores(tmp_path):
100
+ # Un fallo de pypdf no es prueba de cifrado: rechazarlo daría falsos
101
+ # positivos con PDF que el motor propio sí lee.
102
+ origen = tmp_path / "raro.pdf"
103
+ origen.write_bytes(b"%PDF-1.4\nesto no es un pdf completo\n")
104
+ validar_contenedor(origen)
105
+
106
+
107
+ def test_sin_pypdf_no_se_rechaza_nada(tmp_path, monkeypatch):
108
+ # El paquete de PyPI no exige pypdf: sin él no se adivina.
109
+ origen = _pdf(tmp_path, usuario="secreta")
110
+ monkeypatch.setitem(sys.modules, "pypdf", None)
111
+ validar_contenedor(origen)
112
+
113
+
114
+ def test_office_cifrado_es_cifrado(tmp_path):
115
+ # Word/Excel guardan un OOXML con contraseña como contenedor OLE con un
116
+ # flujo «EncryptedPackage», no como zip.
117
+ origen = tmp_path / "presupuesto.xlsx"
118
+ origen.write_bytes(_cfb(["EncryptionInfo", "EncryptedPackage"]))
119
+ with pytest.raises(DocumentoCifrado, match="password"):
120
+ validar_contenedor(origen)
121
+
122
+
123
+ @pytest.mark.parametrize("flujos, contenido", [
124
+ (["WordDocument"], b""), # .doc renombrado a .docx
125
+ (["Workbook"], "EncryptedPackage".encode("utf-16-le")), # el texto, en una celda
126
+ ])
127
+ def test_cfb_sin_flujo_cifrado_sigue_siendo_ilegible_no_cifrado(tmp_path, flujos, contenido):
128
+ # Codex R1: el nombre cuenta solo si es una entrada del directorio, no si
129
+ # aparece en el contenido de otro flujo.
130
+ origen = tmp_path / "viejo.docx"
131
+ origen.write_bytes(_cfb(flujos, contenido))
132
+ with pytest.raises(DocumentoIlegible) as exc:
133
+ validar_contenedor(origen)
134
+ assert not isinstance(exc.value, DocumentoCifrado)
135
+
136
+
137
+ def test_cfb_truncado_no_se_declara_cifrado(tmp_path):
138
+ origen = tmp_path / "corto.xlsx"
139
+ origen.write_bytes(_cfb(["EncryptedPackage"])[:600]) # el directorio no llega
140
+ with pytest.raises(DocumentoIlegible) as exc:
141
+ validar_contenedor(origen)
142
+ assert not isinstance(exc.value, DocumentoCifrado)
143
+
144
+
145
+ def test_cli_rechaza_el_pdf_cifrado_con_codigo_2_y_sin_motores(tmp_path, monkeypatch, capsys):
146
+ llamadas = []
147
+ monkeypatch.setattr(tablewise_cli, "_ruta_modulo", lambda n: llamadas.append(n))
148
+ monkeypatch.setattr(tablewise_cli, "_python_motores", lambda a: Path(sys.executable))
149
+ monkeypatch.setattr(tablewise_cli, "_markitdown", lambda a: "markitdown")
150
+ origen = _pdf(tmp_path, usuario="secreta")
151
+
152
+ codigo = tablewise_cli.main([str(origen), "-o", str(tmp_path / "repo"), "-q"])
153
+
154
+ err = capsys.readouterr().err
155
+ assert codigo == 2
156
+ assert "password" in err.lower()
157
+ assert len(err.strip().splitlines()) == 1 # una línea, sin traza ni detalle
158
+ assert "Traceback" not in err
159
+ assert llamadas == []
@@ -16,6 +16,16 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
16
16
  import sprint_deliver # noqa: E402
17
17
 
18
18
 
19
+
20
+ def _ooxml(ruta, marca: str = "") -> None:
21
+ """Contenedor OOXML mínimo y legible: desde el 14/11 la CLI y la app
22
+ rechazan antes de convertir un .xlsx/.docx que no es un zip de verdad.
23
+ `marca` distingue un fichero de otro, como hacía el texto plano."""
24
+ import zipfile
25
+ with zipfile.ZipFile(ruta, "w") as z:
26
+ z.writestr("[Content_Types].xml", "<Types/>")
27
+ z.writestr("marca.txt", marca)
28
+
19
29
  @pytest.fixture()
20
30
  def motor(monkeypatch, tmp_path):
21
31
  falso = tmp_path / "motor_excel.py"
@@ -35,9 +45,9 @@ def motor(monkeypatch, tmp_path):
35
45
  def _corpus(root: Path) -> Path:
36
46
  src = root / "corpus"
37
47
  (src / "2026" / "q3").mkdir(parents=True)
38
- (src / "pl-enero.xlsx").write_text("a", encoding="utf-8")
39
- (src / "2026" / "q3" / "pl-julio.xlsx").write_text("bb", encoding="utf-8")
40
- (src / "roto.xlsx").write_text("c", encoding="utf-8")
48
+ _ooxml(src / "pl-enero.xlsx", "a")
49
+ _ooxml(src / "2026" / "q3" / "pl-julio.xlsx", "bb")
50
+ _ooxml(src / "roto.xlsx", "c")
41
51
  (src / ".DS_Store").write_text("x", encoding="utf-8")
42
52
  return src
43
53
 
@@ -108,7 +118,7 @@ def test_rerun_script_is_portable(tmp_path, motor):
108
118
 
109
119
  def test_optional_engine_formats_use_the_api_router(monkeypatch, tmp_path, motor):
110
120
  src = _corpus(tmp_path)
111
- (src / "memo.docx").write_text("x", encoding="utf-8")
121
+ _ooxml(src / "memo.docx", "x")
112
122
  seen = []
113
123
 
114
124
  class Result:
@@ -128,7 +138,7 @@ def test_optional_engine_formats_use_the_api_router(monkeypatch, tmp_path, motor
128
138
 
129
139
  def test_same_stem_does_not_overwrite_and_engine_is_normalised(tmp_path, motor):
130
140
  src = _corpus(tmp_path)
131
- (src / "pl-enero.xlsm").write_text("x", encoding="utf-8")
141
+ _ooxml(src / "pl-enero.xlsm", "x")
132
142
  out = tmp_path / "d"
133
143
  report = sprint_deliver.build(src, out, client="Acme", quiet=True)
134
144
  assert report.converted == 3 and report.failed == ["roto.xlsx"]
@@ -143,7 +153,7 @@ def test_same_stem_does_not_overwrite_and_engine_is_normalised(tmp_path, motor):
143
153
 
144
154
  def test_rerun_handles_same_stem_collisions(tmp_path, motor):
145
155
  src = _corpus(tmp_path)
146
- (src / "pl-enero.xlsm").write_text("x", encoding="utf-8")
156
+ _ooxml(src / "pl-enero.xlsm", "x")
147
157
  out = tmp_path / "d"
148
158
  sprint_deliver.build(src, out, client="Acme", quiet=True)
149
159
 
@@ -177,7 +187,7 @@ printf '# %s\n' "$name" > "$dest/$name.md"
177
187
 
178
188
  def test_sanitised_name_matches_the_manifest_and_the_file(tmp_path, motor):
179
189
  src = _corpus(tmp_path)
180
- (src / ".informe.xlsx").write_text("x", encoding="utf-8")
190
+ _ooxml(src / ".informe.xlsx", "x")
181
191
  out = tmp_path / "d"
182
192
  report = sprint_deliver.build(src, out, client="Acme", quiet=True)
183
193
  assert report.converted == 3 and report.failed == ["roto.xlsx"]
@@ -190,7 +200,7 @@ def test_sanitised_name_matches_the_manifest_and_the_file(tmp_path, motor):
190
200
 
191
201
  def test_rerun_groups_skipped_files_for_collision_names(monkeypatch, tmp_path, motor):
192
202
  src = _corpus(tmp_path)
193
- (src / "presupuesto.xlsx").write_text("x", encoding="utf-8")
203
+ _ooxml(src / "presupuesto.xlsx", "x")
194
204
  (src / "presupuesto.pdf").write_text("x", encoding="utf-8")
195
205
 
196
206
  class Result:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes