From 345c39417be959b95af1bcbb8170df9c84bbede5 Mon Sep 17 00:00:00 2001 From: sirdave73 Date: Wed, 2 Sep 2026 01:53:24 -0400 Subject: [PATCH] any-file to PDF (Phase 1): converters/ dispatcher, images/text/md/csv/json/xml/svg/pdf-passthrough, 112 tests green --- Dockerfile | 1 + app.py | 49 +++- converters/__init__.py | 85 ++++++ converters/common.py | 188 ++++++++++++++ converters/csv_converter.py | 71 +++++ converters/html_converter.py | 67 +++++ converters/image_converter.py | 53 ++++ converters/json_converter.py | 38 +++ converters/md_converter.py | 54 ++++ converters/pdf_passthrough.py | 66 +++++ converters/svg_converter.py | 55 ++++ converters/text_converter.py | 55 ++++ converters/xml_converter.py | 51 ++++ requirements.txt | 3 + templates/index.html | 13 +- tests/test_edge_cases.py | 3 +- tests/test_multi_format.py | 471 ++++++++++++++++++++++++++++++++++ 17 files changed, 1306 insertions(+), 17 deletions(-) create mode 100644 converters/__init__.py create mode 100644 converters/common.py create mode 100644 converters/csv_converter.py create mode 100644 converters/html_converter.py create mode 100644 converters/image_converter.py create mode 100644 converters/json_converter.py create mode 100644 converters/md_converter.py create mode 100644 converters/pdf_passthrough.py create mode 100644 converters/svg_converter.py create mode 100644 converters/text_converter.py create mode 100644 converters/xml_converter.py create mode 100644 tests/test_multi_format.py diff --git a/Dockerfile b/Dockerfile index 556e65e..074b58c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY app.py . COPY templates/ ./templates/ +COPY converters/ ./converters/ # Persistent data (uploads/output/work) — mount a volume at /app/data VOLUME ["/app/data"] diff --git a/app.py b/app.py index 90d5026..13188f4 100644 --- a/app.py +++ b/app.py @@ -34,6 +34,8 @@ from werkzeug.exceptions import HTTPException, RequestEntityTooLarge from lxml import etree from pypdf import PdfWriter +import converters # PHASE 1: multi-format dispatch (any common file -> PDF) + BASE_DIR = Path(__file__).resolve().parent # DATA_DIR is overridable via env so container deployments (e.g. Coolify) # can point uploads/output/work at a persistent volume. @@ -46,6 +48,12 @@ for _d in (UPLOAD_DIR, OUTPUT_DIR, WORK_DIR): MAX_UPLOAD_MB = 400 +# req 3 (health): supported formats advertised to clients. Additive key — the +# existing /health contract asserts only status + max_upload_mb. +SUPPORTED_FORMATS = ["epub", "html", "htm", "md", "markdown", "txt", "log", + "csv", "tsv", "json", "xml", "svg", "png", "jpg", + "jpeg", "webp", "tiff", "bmp", "pdf"] + # ZIP-bomb guard: total uncompressed size must stay within 8x the upload # size, with a 2 GiB floor so normal books always fit (finding: zip bomb, # app.py extractall). @@ -127,6 +135,10 @@ body { font-size: 10.5pt; line-height: 1.65; color: #1c1b1a; + /* WeasyPrint emits CSS kerning as positive TJ adjustments that + pypdf's extract_text misreads as inter-word spaces ("BRA VO"); + disable it to keep text extraction faithful. */ + font-kerning: none; } h1, h2, h3, h4, h5, h6 { font-weight: 700; line-height: 1.25; color: #111; @@ -526,8 +538,9 @@ def _run_job(job_id: str, epub_path: Path) -> None: _set_job(job_id, progress=pct, message=msg) try: - filename = convert_epub(epub_path, job_id, progress_cb=_cb, - source_name=JOBS.get(job_id, {}).get("epub")) + filename = converters.convert_file( + epub_path, job_id, progress_cb=_cb, + source_name=JOBS.get(job_id, {}).get("epub")) _set_job(job_id, status="done", progress=100.0, message="Done", filename=filename, error=None) except Exception as exc: # noqa: BLE001 - any failure is a job error @@ -539,7 +552,10 @@ def _run_job(job_id: str, epub_path: Path) -> None: except ValueError: pass try: - (UPLOAD_DIR / f"{job_id}.epub").unlink(missing_ok=True) + # PHASE 1: uploads keep their real suffix ({job_id}.html, ...); + # job_id is 32-hex so the glob cannot match anything else. + for _up in UPLOAD_DIR.glob(f"{job_id}.*"): + _up.unlink(missing_ok=True) except OSError: pass _sweep_stale_work_dirs() @@ -571,9 +587,11 @@ def health(): problems.append("weasyprint not importable") if not _cjk_fonts_present(): problems.append("CJK fonts missing (fc-list found none)") - body = {"status": "ok", "max_upload_mb": MAX_UPLOAD_MB} + body = {"status": "ok", "max_upload_mb": MAX_UPLOAD_MB, + "supported_formats": SUPPORTED_FORMATS} if problems: - body = {"status": "degraded", "max_upload_mb": MAX_UPLOAD_MB, "problems": problems} + body = {"status": "degraded", "max_upload_mb": MAX_UPLOAD_MB, + "supported_formats": SUPPORTED_FORMATS, "problems": problems} return jsonify(body), 503 return jsonify(body), 200 @@ -583,15 +601,21 @@ def convert_upload(): up = request.files.get("file") if up is None or not up.filename: return jsonify(error="No file uploaded"), 400 - if not up.filename.lower().endswith(".epub"): - return jsonify(error="Only .epub files are accepted"), 400 + if not converters.is_supported(up.filename): + # PHASE 1: gate on the full extension matrix; the error lists every + # accepted extension (incl. .epub) so the legacy 400 contract holds. + ext = Path(up.filename).suffix or "" + return jsonify(error=( + f"Unsupported file type: '{ext}'. " + f"Supported: {', '.join(sorted(converters.SUPPORTED_EXTENSIONS))}" + )), 400 data = up.read() if len(data) > MAX_UPLOAD_MB * 1024 * 1024: return jsonify(error=f"File too large (max {MAX_UPLOAD_MB} MB)"), 400 if not WORKER_SLOTS.acquire(blocking=False): return jsonify(error="Server busy: all conversion slots in use, retry shortly"), 503 job_id = _register_job(Path(up.filename).name) - dest = UPLOAD_DIR / f"{job_id}.epub" + dest = UPLOAD_DIR / f"{job_id}{Path(up.filename).suffix.lower()}" try: with open(dest, "wb") as out: out.write(data) @@ -621,9 +645,12 @@ def download(name): return jsonify(error="unknown file"), 404 # req 4: explicit PDF mimetype + defensive .pdf on download_name download_name = safe if safe.lower().endswith(".pdf") else safe + ".pdf" - return send_file( + resp = send_file( target, as_attachment=True, mimetype="application/pdf", download_name=download_name ) + # contract: quoted filename in Content-Disposition + resp.headers["Content-Disposition"] = f'attachment; filename="{download_name}"' + return resp # -------------------------------------------------------------------------- @@ -655,7 +682,9 @@ def _run_cli(argv: list[str]) -> int: print(f"[{pct:5.1f}%] {message}") try: - filename = convert_epub(src, job_id, progress_cb=_cb, output_dir=out_dir) + filename = converters.convert_file(src, job_id, progress_cb=_cb, + output_dir=out_dir, + source_name=src.name) except Exception as exc: # noqa: BLE001 - CLI reports any failure as error: print(f"error: {exc}") return 1 diff --git a/converters/__init__.py b/converters/__init__.py new file mode 100644 index 0000000..2b07f07 --- /dev/null +++ b/converters/__init__.py @@ -0,0 +1,85 @@ +"""converters — PHASE 1 multi-format dispatch (any common file -> PDF). + +Public surface: + is_supported(name) -> bool (extension matrix, case-insensitive) + convert_file(path, job_id, progress_cb=None, output_dir=None, + source_name=None) -> output filename (str) + +Routing is lazy per extension so an unsupported upload never pulls in an +optional dependency (e.g. PIL) it would not need. .epub routes to the +existing EPUB pipeline (app.convert_epub) — resolved lazily to keep the +import graph acyclic (app imports this package at module level). + +Every converter shares one error contract: problems are ValueError with a +human message (the worker/CLI surface it verbatim as a job error / CLI +'error:' line); naming follows the pinned contract in converters.common. +""" +from __future__ import annotations + +from pathlib import Path + +__all__ = ["SUPPORTED_EXTENSIONS", "is_supported", "convert_file"] + +# Extension -> converter module (resolved lazily inside convert_file). +_ROUTING: dict[str, str] = { + ".html": "html_converter", + ".htm": "html_converter", + ".txt": "text_converter", + ".log": "text_converter", + ".md": "md_converter", + ".markdown": "md_converter", + ".csv": "csv_converter", + ".tsv": "csv_converter", + ".json": "json_converter", + ".xml": "xml_converter", + ".svg": "svg_converter", + ".png": "image_converter", + ".jpg": "image_converter", + ".jpeg": "image_converter", + ".webp": "image_converter", + ".tiff": "image_converter", + ".bmp": "image_converter", + ".pdf": "pdf_passthrough", +} + +# .epub is supported (existing pipeline) but routes differently. +SUPPORTED_EXTENSIONS = frozenset(_ROUTING) | {".epub"} + + +def is_supported(filename: str) -> bool: + """True if `filename`'s extension is in the supported matrix. + + Case-insensitive; extensionless names are unsupported. + """ + if not filename: + return False + ext = Path(filename).suffix.lower() + return ext in SUPPORTED_EXTENSIONS + + +def convert_file(path: Path, job_id: str, progress_cb=None, output_dir=None, + source_name=None) -> str: + """Convert `path` to a PDF; return the output file name (no dir). + + `source_name` is the original upload/CLI file name — the on-disk name + for web uploads is `{job_id}`, so the export name must be derived + from the original name, not the temp path. + + Raises ValueError for unsupported extensions and for any format-level + failure (empty/invalid content) — see each converter's docstring. + """ + ext = Path(path).suffix.lower() + if ext not in SUPPORTED_EXTENSIONS: + raise ValueError( + f"Unsupported file type: '{ext or ''}'. " + f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}" + ) + if ext == ".epub": + import app # lazy: the EPUB pipeline lives in app.py (avoid cycle) + + return app.convert_epub(path, job_id, progress_cb=progress_cb, + output_dir=output_dir, source_name=source_name) + module_name = _ROUTING[ext] + module = __import__(f"converters.{module_name}", fromlist=["convert"]) + return module.convert(Path(path), job_id, progress_cb=progress_cb, + output_dir=output_dir, source_name=source_name) diff --git a/converters/common.py b/converters/common.py new file mode 100644 index 0000000..f18879b --- /dev/null +++ b/converters/common.py @@ -0,0 +1,188 @@ +"""Shared building blocks for the PHASE 1 multi-format converters. + +The EPUB pipeline (app.convert_epub) keeps its own inline naming code; the +logic here is an intentional mirror of that pinned contract (sanitize +[^A-Za-z0-9._ -]+ -> '_', 60-char cap, no doubled .pdf, first-free +'-2'/'-3' collision suffix) so every format exports names identically. + +No module-level import of app: app.py imports converters at top level, so +app references are resolved lazily inside functions. +""" +from __future__ import annotations + +import html as _html +import io +import os +import re +from pathlib import Path + +__all__ = [ + "PRE_CSS", + "convert_single", + "decode_declared", + "decode_text", + "derive_export_name", + "escape", + "finalize_pdf", + "html_to_pdf_bytes", + "report", +] + +# CSS for
-based outputs (txt, log, json, xml): monospace, wrapped.
+PRE_CSS = """
+@page { size: A4; margin: 18mm 16mm; }
+body {
+    font-family: "DejaVu Sans Mono", "Courier New", monospace;
+    font-size: 9pt; line-height: 1.5; color: #1c1b1a;
+    font-kerning: none; /* pypdf misreads WeasyPrint kerning as spaces */
+}
+pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; }
+"""
+
+
+def escape(text: str) -> str:
+    """HTML-escape for safe embedding in generated 
/ bodies."""
+    return _html.escape(text, quote=False)
+
+
+def default_output_dir(output_dir=None) -> Path:
+    """CLI-supplied output dir (created), else the app-managed OUTPUT_DIR."""
+    if output_dir:
+        d = Path(output_dir)
+        d.mkdir(parents=True, exist_ok=True)
+        return d
+    import app  # lazy: avoids a module-level import cycle
+    return app.OUTPUT_DIR
+
+
+def decode_declared(raw: bytes) -> str:
+    """Decode honoring an XML-declaration /  encoding, else UTF-8.
+
+    Reuses the pinned charset-detection regexes from the EPUB path so an HTML
+    file with a windows-1252 declaration decodes like an EPUB chapter.
+    Undecodable input never raises.
+    """
+    import app
+    import codecs
+
+    m = app._XML_DECL_ENC.search(raw[:4096]) or app._META_ENC.search(raw[:8192])
+    if m:
+        enc = m.group(1).decode("ascii", errors="replace").strip()
+        try:
+            codecs.lookup(enc)
+            return raw.decode(enc)
+        except (LookupError, UnicodeDecodeError):
+            pass
+    return raw.decode("utf-8", errors="replace")
+
+
+def decode_text(raw: bytes) -> str:
+    """Best-effort text decode: strict UTF-8, then charset-normalizer, then replace.
+
+    Strict UTF-8 first so valid UTF-8 is never second-guessed; the
+    charset-normalizer pass catches legacy encodings (windows-1252, latin-1)
+    without mojibake.
+    """
+    try:
+        return raw.decode("utf-8")
+    except UnicodeDecodeError:
+        pass
+    from charset_normalizer import from_bytes
+
+    best = from_bytes(raw).best()
+    if best is not None:
+        return str(best)
+    return raw.decode("utf-8", errors="replace")
+
+
+def derive_export_name(filename: str, out_dir: Path, title: str | None = None) -> str:
+    """Collision-safe PDF name, mirroring the pinned EPUB export-name contract.
+
+    base = clean(title) if title else clean(stem); empty -> 'book'.
+    clean: [^A-Za-z0-9._ -]+ -> '_', [:60] cap, strip trailing '.pdf'
+    (case-insensitive). Collision: first free of base.pdf, base-2.pdf, ... .
+    """
+    stem = Path(filename).stem
+    if re.fullmatch(r"\.\w+", stem):
+        # bare dotfile (".txt"): Path keeps the dot in .stem; treat as empty
+        stem = ""
+    if title and re.sub(r"[^A-Za-z0-9._ -]+", "_", title).strip():
+        base = re.sub(r"[^A-Za-z0-9._ -]+", "_", title).strip()[:60].strip()
+    else:
+        base = re.sub(r"[^A-Za-z0-9._ -]+", "_", stem).strip()[:60].strip() or "book"
+    if base.lower().endswith(".pdf"):
+        base = base[:-4].rstrip() or "book"
+    filename_out = f"{base}.pdf"
+    counter = 2
+    while (Path(out_dir) / filename_out).exists():
+        filename_out = f"{base}-{counter}.pdf"
+        counter += 1
+    return filename_out
+
+
+def html_to_pdf_bytes(html: str, css: str | None = None) -> bytes:
+    """Render an HTML string to PDF bytes via WeasyPrint.
+
+    css: extra stylesheet (e.g. PRE_CSS for text dumps). Empty/None means
+    the document carries its own "
+        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,
+    )
diff --git a/converters/html_converter.py b/converters/html_converter.py
new file mode 100644
index 0000000..a8262e8
--- /dev/null
+++ b/converters/html_converter.py
@@ -0,0 +1,67 @@
+"""HTML / HTM -> PDF.
+
+A standalone .html upload is rendered as-is: the document's own "
+    m = re.search(r"]*>", html_text, re.IGNORECASE)
+    if m:
+        return html_text[: m.end()] + style + html_text[m.end():]
+    return style + html_text
+
+
+def convert(path: Path, job_id: str, progress_cb=None, output_dir=None,
+            source_name=None) -> str:
+    """Render a standalone HTML/HTM file to a typeset PDF."""
+    raw = path.read_bytes()
+    html_text = common.decode_declared(raw)
+    if not html_text.strip():
+        raise ValueError("empty file: nothing to convert")
+    title = _extract_title(html_text)
+
+    def _build():
+        return _inject_base_css(html_text), path.name
+
+    return common.convert_single(
+        path, job_id, build_html=_build, title=title, css="",
+        progress_cb=progress_cb, output_dir=output_dir,
+        name_src=source_name or path.name,
+    )
diff --git a/converters/image_converter.py b/converters/image_converter.py
new file mode 100644
index 0000000..e608326
--- /dev/null
+++ b/converters/image_converter.py
@@ -0,0 +1,53 @@
+"""Raster images (.png/.jpg/.jpeg/.webp/.tiff/.bmp) -> PDF, one page per image.
+
+Rendered directly by Pillow (img.save(format='PDF')) — no WeasyPrint in
+this path, so image fidelity is exactly what the encoder produces.
+Transparency (RGBA/P) is flattened to RGB over white. Images wider or
+taller than MAX_DIM are downscaled proportionally to keep the PDF sane
+for e.g. 12 MP camera photos.
+
+An unopenable/corrupt file is a job error: "Invalid image: ..." (the
+contract asserts 'image' in error.lower()).
+"""
+from __future__ import annotations
+
+import io
+from pathlib import Path
+
+from PIL import Image
+
+from . import common
+
+MAX_DIM = 2000
+
+
+def convert(path: Path, job_id: str, progress_cb=None, output_dir=None,
+            source_name=None) -> str:
+    """Render a raster image to a one-page PDF."""
+    try:
+        img = Image.open(path)
+        img.load()  # force full decode: a truncated file fails here, not later
+    except Exception as exc:  # noqa: BLE001 - PIL raises many types
+        raise ValueError(f"Invalid image: {path.name} could not be opened ({exc})") from exc
+
+    if img.mode in ("RGBA", "P"):
+        bg = Image.new("RGB", img.size, (255, 255, 255))
+        if img.mode == "P":
+            img = img.convert("RGBA")
+        bg.paste(img, mask=img.getchannel("A"))
+        img = bg
+    elif img.mode != "RGB":
+        img = img.convert("RGB")
+
+    w, h = img.size
+    scale = min(1.0, MAX_DIM / max(w, h))
+    if scale < 1.0:
+        img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
+
+    common.report(progress_cb, 30.0, f"Rendering {path.name} (1/1)")
+    buf = io.BytesIO()
+    img.save(buf, format="PDF")
+    out_dir = common.default_output_dir(output_dir)
+    filename = common.derive_export_name(source_name or path.name, out_dir)
+    return common.finalize_pdf(buf.getvalue(), out_dir, filename, job_id,
+                               progress_cb=progress_cb)
diff --git a/converters/json_converter.py b/converters/json_converter.py
new file mode 100644
index 0000000..df8aef6
--- /dev/null
+++ b/converters/json_converter.py
@@ -0,0 +1,38 @@
+"""JSON -> PDF (pretty-printed, monospace).
+
+Validation is explicit (a JSON error is a *job* error with a clear message,
+not a render failure): whitespace-only input -> "empty JSON file: ...",
+unparseable input -> "Invalid JSON: ". Valid documents are
+pretty-printed (indent=2, original key order) into 
 with PRE_CSS so
+structure and indentation survive the render.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from . import common
+from .text_converter import decode_bytes
+
+
+def convert(path: Path, job_id: str, progress_cb=None, output_dir=None,
+            source_name=None) -> str:
+    """Render a JSON file to a pretty-printed monospace PDF."""
+    raw = path.read_bytes()
+    if not raw.strip():
+        raise ValueError("empty JSON file: nothing to convert")
+    text = decode_bytes(raw)
+    try:
+        obj = json.loads(text)
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"Invalid JSON: {exc}") from exc
+    pretty = json.dumps(obj, indent=2, ensure_ascii=False)
+
+    def _build():
+        return f"
{common.escape(pretty)}
", path.name + + return common.convert_single( + path, job_id, build_html=_build, css=common.PRE_CSS, + progress_cb=progress_cb, output_dir=output_dir, + name_src=source_name or path.name, + ) diff --git a/converters/md_converter.py b/converters/md_converter.py new file mode 100644 index 0000000..2131858 --- /dev/null +++ b/converters/md_converter.py @@ -0,0 +1,54 @@ +"""Markdown (.md / .markdown) -> PDF. + +Rendered with the `markdown` library (tables, fenced code, nl2br) and +typeset by WeasyPrint with app.BASE_CSS injected at the top of so +the fallback layer sits under any user CSS. The first ATX H1 becomes the +export name and PDF /Title (it stays in the rendered document for visual +fidelity); without an H1 the file stem is used. + +Empty input renders 0 pages and fails in finalize with +"no renderable content: ..." — unpinned, acceptable. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from . import common + +_FIRST_H1_RE = re.compile(r"^\s*#\s+(.+?)\s*#*\s*$", re.MULTILINE) + + +def _extract_h1(md_text: str) -> str | None: + m = _FIRST_H1_RE.search(md_text) + if not m: + return None + title = re.sub(r"[#*`_]", "", m.group(1)) + title = " ".join(title.split()) + return title or None + + +def convert(path: Path, job_id: str, progress_cb=None, output_dir=None, + source_name=None) -> str: + """Render a Markdown file to a typeset PDF.""" + import app # lazy: BASE_CSS lives with the EPUB pipeline + import markdown + + text = common.decode_text(path.read_bytes()) + title = _extract_h1(text) + body_html = markdown.markdown( + text, extensions=["tables", "fenced_code", "nl2br"] + ) + html_doc = ( + f"" + f"{body_html}" + ) + + def _build(): + return html_doc, path.name + + return common.convert_single( + path, job_id, build_html=_build, title=title, css="", + progress_cb=progress_cb, output_dir=output_dir, + name_src=source_name or path.name, + ) diff --git a/converters/pdf_passthrough.py b/converters/pdf_passthrough.py new file mode 100644 index 0000000..46bd266 --- /dev/null +++ b/converters/pdf_passthrough.py @@ -0,0 +1,66 @@ +"""PDF -> PDF passthrough (.pdf uploads). + +A valid, unencrypted PDF is validated with pypdf and passed through to the +output with the standard naming/collision contract, re-stamping /Title from +the uploaded filename stem. It reuses common.finalize_pdf so the atomic +write path (.tmp + os.replace) and metadata handling are identical to every +other converter. + +Rejections (all ValueError -> surfaced verbatim as a job error / CLI error): + - corrupt / unparseable -> "Invalid PDF: ... could not be parsed" + - encrypted / password -> "PDF rejected: ... encrypted or password-protected" + - more than MAX_PAGES -> "PDF too large: ... pages (max N)" +""" +from __future__ import annotations + +import io +from pathlib import Path + +from pypdf import PdfReader + +from . import common + +# req 1 (pdf passthrough): upper bound on page count for a pass-through PDF. +MAX_PAGES = 500 + + +def convert(path: Path, job_id: str, progress_cb=None, output_dir=None, + source_name=None) -> str: + """Validate a PDF and pass it through to a same-named output PDF. + + Satisfies: req 1 (pdf passthrough — validate, stamp /Title, standard + naming/collision, atomic write). + """ + name = source_name or path.name + stem = Path(name).stem or "book" + raw = path.read_bytes() + + # req 1: validate with pypdf. Construction raises on header/body corruption. + try: + reader = PdfReader(io.BytesIO(raw)) + except Exception as exc: # noqa: BLE001 - pypdf raises many corrupt-file types + raise ValueError(f"Invalid PDF: {name} could not be parsed ({exc})") from exc + + # req 1: reject encrypted / password-protected BEFORE touching .pages + # (accessing pages on an encrypted reader raises, not a clean error). + if reader.is_encrypted: + raise ValueError(f"PDF rejected: {name} is encrypted or password-protected") + + # req 1: count pages; a corrupt body beyond a valid header fails here. + try: + page_count = len(reader.pages) + except Exception as exc: # noqa: BLE001 + raise ValueError(f"Invalid PDF: {name} could not be parsed ({exc})") from exc + + # req 1: reject oversized documents (clear error, distinct from corrupt). + if page_count > MAX_PAGES: + raise ValueError( + f"PDF too large: {name} has {page_count} pages (max {MAX_PAGES})" + ) + + # req 1: stamp /Title from the uploaded filename stem; standard naming/collision. + common.report(progress_cb, 30.0, f"Passing through {name} (1/1)") + out_dir = common.default_output_dir(output_dir) + filename = common.derive_export_name(name, out_dir) + return common.finalize_pdf(raw, out_dir, filename, job_id, + progress_cb=progress_cb, title=stem) diff --git a/converters/svg_converter.py b/converters/svg_converter.py new file mode 100644 index 0000000..8aef3c2 --- /dev/null +++ b/converters/svg_converter.py @@ -0,0 +1,55 @@ +"""SVG -> PDF (vector render via WeasyPrint). + +SVG is XML by spec, so the document is first well-formedness-checked with +lxml — an unbalanced file (e.g. "") is a *job* error with +"Invalid SVG: ", never a silent blank render (HTML5 foreign-content +parsing would otherwise swallow the mismatch and succeed with an empty page). + +The SVG is then embedded verbatim in a minimal A4 HTML document; WeasyPrint +renders SVG natively as a vector (text stays sharp, no rasterization). A +forced page break guarantees at least one PDF page even for a degenerate +document. A WeasyPrint render failure is also reported as "Invalid SVG: ...". +""" +from __future__ import annotations + +from pathlib import Path + +from lxml import etree + +from . import common + +_DOC = ( + "{svg}
" + "" +) + + +def convert(path: Path, job_id: str, progress_cb=None, output_dir=None, + source_name=None) -> str: + """Render an SVG file to a vector PDF.""" + raw = path.read_bytes() + if not raw.strip(): + raise ValueError("empty file: nothing to convert") + svg_text = common.decode_text(raw) + try: + etree.fromstring(svg_text.encode("utf-8")) + except etree.XMLSyntaxError as exc: + raise ValueError(f"Invalid SVG: {exc}") from exc + + doc = _DOC.replace("{svg}", svg_text) + out_dir = common.default_output_dir(output_dir) + + common.report(progress_cb, 30.0, f"Rendering {path.name} (1/1)") + try: + pdf_bytes = common.html_to_pdf_bytes(doc) + except ValueError: + raise # empty-file errors keep their own message + except Exception as exc: # noqa: BLE001 - render failure is a job error + raise ValueError(f"Invalid SVG: {exc}") from exc + filename = common.derive_export_name(source_name or path.name, out_dir) + return common.finalize_pdf(pdf_bytes, out_dir, filename, job_id, + progress_cb=progress_cb) diff --git a/converters/text_converter.py b/converters/text_converter.py new file mode 100644 index 0000000..0a6c488 --- /dev/null +++ b/converters/text_converter.py @@ -0,0 +1,55 @@ +"""Plain text (.txt) and log (.log) -> PDF. + +Decode ladder (no mojibake on legacy files, never raises): + 1. strict UTF-8 -> valid UTF-8 is never second-guessed + 2. strict windows-1252 -> its 5 undefined byte holes (0x81/8D/8F/90/9D) + make a successful strict decode a reliable + cp1252 signal (covers latin-1-ish content too) + 3. charset-normalizer -> best guess for other legacy encodings + 4. UTF-8 errors=replace -> last resort, total losslessness guarantee + +Empty / whitespace-only input is a job error ("empty file: ..."). The text +is wrapped in
 with PRE_CSS (monospace, wrapped, A4) so line structure
+and indentation survive the render.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from . import common
+
+
+def decode_bytes(raw: bytes) -> str:
+    """Best-effort decode ladder; see module docstring."""
+    try:
+        return raw.decode("utf-8")
+    except UnicodeDecodeError:
+        pass
+    try:
+        return raw.decode("cp1252")
+    except UnicodeDecodeError:
+        pass
+    from charset_normalizer import from_bytes
+
+    best = from_bytes(raw).best()
+    if best is not None:
+        return str(best)
+    return raw.decode("utf-8", errors="replace")
+
+
+def convert(path: Path, job_id: str, progress_cb=None, output_dir=None,
+            source_name=None) -> str:
+    """Render a plain-text or log file to a monospace PDF."""
+    raw = path.read_bytes()
+    if not raw.strip():
+        raise ValueError("empty file: nothing to convert")
+    text = decode_bytes(raw)
+
+    def _build():
+        return f"
{common.escape(text)}
", path.name + + return common.convert_single( + path, job_id, build_html=_build, css=common.PRE_CSS, + progress_cb=progress_cb, output_dir=output_dir, + name_src=source_name or path.name, + ) diff --git a/converters/xml_converter.py b/converters/xml_converter.py new file mode 100644 index 0000000..a181878 --- /dev/null +++ b/converters/xml_converter.py @@ -0,0 +1,51 @@ +"""XML -> PDF (pretty text dump, monospace). + +Validation is explicit: lxml must parse the document, otherwise the job +fails with "Invalid XML: " (never a render failure). A valid +document is rendered as its text content — every text node and tail in +document order, one per line, whitespace-only nodes dropped — inside +
 with PRE_CSS. Attribute values are intentionally not dumped: the
+contract is that element text survives extraction.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from lxml import etree
+
+from . import common
+from .text_converter import decode_bytes
+
+
+def convert(path: Path, job_id: str, progress_cb=None, output_dir=None,
+            source_name=None) -> str:
+    """Render an XML file to a monospace text-dump PDF."""
+    raw = path.read_bytes()
+    if not raw.strip():
+        raise ValueError("empty file: nothing to convert")
+    text = decode_bytes(raw)
+    try:
+        root = etree.fromstring(text.encode("utf-8"))
+    except etree.XMLSyntaxError as exc:
+        raise ValueError(f"Invalid XML: {exc}") from exc
+
+    lines: list[str] = []
+    for el in root.iter():
+        for chunk in (el.text, el.tail):
+            if not chunk:
+                continue
+            piece = " ".join(chunk.split())
+            if piece:
+                lines.append(piece)
+    if not lines:
+        raise ValueError("empty file: XML has no text content")
+    body = "\n".join(lines)
+
+    def _build():
+        return f"
{common.escape(body)}
", path.name + + return common.convert_single( + path, job_id, build_html=_build, css=common.PRE_CSS, + progress_cb=progress_cb, output_dir=output_dir, + name_src=source_name or path.name, + ) diff --git a/requirements.txt b/requirements.txt index 8b8eda4..0bee6e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ pypdf==6.16.2 beautifulsoup4==4.15.0 lxml==6.1.2 gunicorn==26.2.0 +markdown==3.10.3 +Pillow==12.3.0 +charset-normalizer==3.4.4 diff --git a/templates/index.html b/templates/index.html index dbbebeb..15f1800 100644 --- a/templates/index.html +++ b/templates/index.html @@ -118,12 +118,12 @@
❦Bindery
-

A local typesetter. Drop an EPUB, take a properly set PDF. Nothing leaves your machine.

+

A local typesetter. Drop a document, take a properly set PDF. Nothing leaves your machine.

-
+
-

Drop a book to set it in type

-

or browse your files · up to 400 MB · .epub

+

Drop a file to set it in type

+

or browse your files · up to 400 MB · .epub .html .md .txt .csv .json .xml .svg .png .jpg .webp …

- +