diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c8ede5e..54d5288 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -28,7 +28,7 @@ Everything runs locally (or on your own server); **no file ever leaves your mach | TeX / LaTeX | `.tex` `.latex` | Via pandoc; needs `\documentclass` or a leading `%`, else treated as plain text | | Extended markup | `.org` `.adoc` `.rst` | Org mode, AsciiDoc, reStructuredText via pandoc | | Text | `.txt` `.log` | UTF-8 safe, paginated | -| Data | `.csv` `.tsv` | Auto-detected delimiter → styled table; max 5000 rows × 50 cols | +| Data | `.csv` `.tsv` | Auto-detected delimiter → styled table; **truncated to 5000 rows × 50 cols** (extra data dropped with a note; split to include more) | | Structured | `.json` `.xml` | Cleanly formatted listing | | Vector | `.svg` | Embedded via WeasyPrint | | Images | `.png` `.jpg` `.jpeg` `.webp` `.tiff` `.bmp` | One PDF page per image; max side 3000 px | @@ -94,7 +94,7 @@ See `COOLIFY.md` in this directory (3 steps: Dockerfile service, port 8030, volu | `DRM protected` style errors | Remove DRM first (Calibre / Calibre-DB tools) — this app can't decrypt | | Stuck at `Working…` forever | The server restarted mid-job; refresh the page and re-upload | | `Unsupported format` | File isn't one of the 27 supported types — see §1 | -| CSV/TSV rejected | Over 5000 rows or 50 columns — split the file | +| CSV/TSV truncated | Over 5000 rows or 50 columns — automatically truncated with a note; split the file to include more | | PDF rejected | Encrypted/password-protected, or over 500 pages | --- diff --git a/app.py b/app.py index 6ca7c05..12045a8 100644 --- a/app.py +++ b/app.py @@ -67,6 +67,7 @@ WORKER_SLOTS = threading.BoundedSemaphore(4) # Crash leftovers: sweep work dirs / uploads older than these at startup. STALE_WORK_SECONDS = 6 * 3600 +STALE_OUTPUT_SECONDS = 6 * 3600 STALE_UPLOAD_SECONDS = 3600 app = Flask(__name__) @@ -374,8 +375,32 @@ def _sweep_stale_work_dirs() -> None: pass +def _sweep_stale_output_files() -> None: + """Remove OUTPUT_DIR files left by crashed runs older than the 6h window. + + Symmetric to _sweep_stale_work_dirs (finding M2: stale output sweep). Only + finished output files are removed (the directory is dedicated to generated + PDFs); files inside the window are left untouched. + """ + now = time.time() + try: + entries = list(OUTPUT_DIR.iterdir()) + except OSError: + return + for entry in entries: + if not entry.is_file(): + continue + try: + if now - entry.stat().st_mtime > STALE_OUTPUT_SECONDS: + entry.unlink(missing_ok=True) + except OSError: + pass + + # Crash leftovers from the previous process start (finding: stale work dirs). _sweep_stale_work_dirs() +# Stale generated outputs from the previous process start (finding M2). +_sweep_stale_output_files() # -------------------------------------------------------------------------- diff --git a/converters/csv_converter.py b/converters/csv_converter.py index d34424e..2ef987f 100644 --- a/converters/csv_converter.py +++ b/converters/csv_converter.py @@ -18,6 +18,20 @@ from . import common from .text_converter import decode_bytes +# H1 render cap: a single oversized CSV must not exhaust the single worker +# thread (unbounded CPU/memory DoS). Rows/columns are TRUNCATED (not rejected), +# and a visible note is appended so the behaviour is honest. Mirrors the +# documented limit (USER_GUIDE.md: max 5000 rows x 50 cols). +MAX_ROWS = 5000 +MAX_COLS = 50 + +# Visible note appended to the rendered table when the cap is exceeded. +_TRUNCATION_NOTE = ( + "
truncated to 5000 rows x 50 cols; " + "split the file to include more
" +) + + def _rows(text: str, delimiter: str) -> list[list[str]]: rows = list(csv.reader(io.StringIO(text), delimiter=delimiter)) return [row for row in rows if any(cell.strip() for cell in row)] @@ -26,13 +40,19 @@ def _rows(text: str, delimiter: str) -> list[list[str]]: def _table_html(rows: list[list[str]]) -> str: if not rows: return "" - header, body = rows[0], rows[1:] - head = "".join(f"| {common.escape(c)} | " for c in cells) + "