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.
- {tablewise-1.1.2 → tablewise-1.1.3}/PKG-INFO +4 -1
- {tablewise-1.1.2 → tablewise-1.1.3}/README.md +3 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/pyproject.toml +1 -1
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/PKG-INFO +4 -1
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/SOURCES.txt +3 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise_app.py +6 -4
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise_cli.py +35 -7
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise_core.py +164 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_app_convertir.py +79 -1
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_cli.py +40 -19
- tablewise-1.1.3/tests/test_cli_paridad_api.py +139 -0
- tablewise-1.1.3/tests/test_contenedor_ilegible.py +119 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_core.py +13 -1
- tablewise-1.1.3/tests/test_documento_cifrado.py +159 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_sprint_deliver.py +18 -8
- {tablewise-1.1.2 → tablewise-1.1.3}/LICENSE +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/motor_docling.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/motor_excel.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/motor_office.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/motor_propio.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/postpro.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/preconversion.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/setup.cfg +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/dependency_links.txt +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/entry_points.txt +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/requires.txt +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tablewise.egg-info/top_level.txt +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_cli_motores.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_docs.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_entradas_externas.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_docling.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_docling_cli.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_docling_convertir.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_excel.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_office.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_propio.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_motor_propio_escaneados.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_preconversion.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_preconversion_ejecutables_falsos.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_preconversion_errores.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_quality.py +0 -0
- {tablewise-1.1.2 → tablewise-1.1.3}/tests/test_red_sintaxis.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
293
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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 "")
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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"
|
|
198
|
-
(entrada / "sub" / "b.xlsx"
|
|
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"
|
|
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"
|
|
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"
|
|
255
|
-
(entrada / "a.xlsm"
|
|
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"
|
|
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"
|
|
283
|
-
(salida / "seed.xlsx"
|
|
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"
|
|
316
|
+
_ooxml(entrada / "a.xlsx", "a")
|
|
296
317
|
(entrada / "b.xls").write_text("b", encoding="utf-8")
|
|
297
|
-
(entrada / "c.xlsx"
|
|
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"
|
|
318
|
-
(entrada / "a.xlsm"
|
|
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"
|
|
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"
|
|
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"
|
|
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.
|
|
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"
|
|
39
|
-
(src / "2026" / "q3" / "pl-julio.xlsx"
|
|
40
|
-
(src / "roto.xlsx"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
File without changes
|