92 lines
3.1 KiB
Python
92 lines
3.1 KiB
Python
"""CSV / TSV -> PDF (typed table).
|
|
|
|
The first row is rendered as the header (<th>); every row becomes a
|
|
<table> row — the table rules from app.BASE_CSS are injected at the top of
|
|
<head> 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 = (
|
|
"<p class=\"warn\">truncated to 5000 rows x 50 cols; "
|
|
"split the file to include more</p>"
|
|
)
|
|
|
|
|
|
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"<th>{common.escape(c)}</th>" for c in header[:MAX_COLS])
|
|
out = [f"<table><tr>{head}</tr>"]
|
|
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("<tr>" + "".join(f"<td>{common.escape(c)}</td>" for c in cells) + "</tr>")
|
|
if truncated:
|
|
out.append(_TRUNCATION_NOTE)
|
|
out.append("</table>")
|
|
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 = (
|
|
"<!DOCTYPE html><html><head><style>\n" + app.BASE_CSS + "\n</style>"
|
|
f"</head><body>{table}</body></html>"
|
|
)
|
|
|
|
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,
|
|
)
|