"""CSV / TSV -> PDF (typed table). The first row is rendered as the header (); every row becomes a row — the table rules from app.BASE_CSS are injected at the top of so they under-layer any user CSS. Delimiter: forced tab for .tsv; for .csv, csv.Sniffer on a sample with a comma fallback (single-line files that the sniffer rejects still render). Empty / whitespace-only input is a job error ("empty file: ..."). """ from __future__ import annotations import csv import io from pathlib import Path 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)] def _table_html(rows: list[list[str]]) -> str: if not rows: return "" # H1: cap to the first MAX_ROWS rows and first MAX_COLS columns. shown = rows[:MAX_ROWS] header, body = shown[0], shown[1:] n_cols = min(len(header), MAX_COLS) head = "".join(f"" for c in header[:MAX_COLS]) out = [f"
{common.escape(c)}
{head}"] truncated = len(rows) > MAX_ROWS for row in body: # pad ragged rows to the (truncated) header width so borders line up cells = row[:MAX_COLS] + [""] * (n_cols - len(row[:MAX_COLS])) out.append("" + "".join(f"" for c in cells) + "") if truncated: out.append(_TRUNCATION_NOTE) out.append("
{common.escape(c)}
") return "\n".join(out) def convert(path: Path, job_id: str, progress_cb=None, output_dir=None, source_name=None) -> str: """Render a CSV or TSV file to a tabular PDF.""" import app # lazy: BASE_CSS lives with the EPUB pipeline raw = path.read_bytes() if not raw.strip(): raise ValueError("empty file: nothing to convert") text = decode_bytes(raw) if path.suffix.lower() == ".tsv": delimiter = "\t" else: try: delimiter = csv.Sniffer().sniff(text[:8192], delimiters=",;\t|").delimiter except csv.Error: delimiter = "," rows = _rows(text, delimiter) table = _table_html(rows) html_doc = ( "" f"{table}" ) def _build(): return html_doc, path.name return common.convert_single( path, job_id, build_html=_build, css="", progress_cb=progress_cb, output_dir=output_dir, name_src=source_name or path.name, )