any-file to PDF (Phase 1): converters/ dispatcher, images/text/md/csv/json/xml/svg/pdf-passthrough, 112 tests green
This commit is contained in:
@@ -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}<ext>`, 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 '<none>'}'. "
|
||||
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)
|
||||
@@ -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 <pre>-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 <pre>/<table> 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 / <meta charset> 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 <style> (HTML/MD/SVG/table converters
|
||||
inject BASE_CSS into the document head so their rules win over the
|
||||
fallback at equal specificity).
|
||||
"""
|
||||
from weasyprint import CSS, HTML
|
||||
|
||||
buf = io.BytesIO()
|
||||
kwargs = {"stylesheets": [CSS(string=css)]} if css else {}
|
||||
HTML(string=html).write_pdf(buf, **kwargs)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def finalize_pdf(pdf_bytes: bytes, out_dir: Path, filename: str,
|
||||
job_id: str, progress_cb=None, title: str | None = None) -> str:
|
||||
"""Add metadata, write atomically (.tmp + os.replace); return the name.
|
||||
|
||||
title: explicit PDF /Title (e.g. HTML <title> or Markdown H1); falls
|
||||
back to the export filename stem when omitted.
|
||||
"""
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
writer = PdfWriter()
|
||||
for page in PdfReader(io.BytesIO(pdf_bytes)).pages:
|
||||
writer.add_page(page)
|
||||
if not writer.pages:
|
||||
raise ValueError("no renderable content: conversion produced 0 PDF pages")
|
||||
writer.add_metadata({"/Title": (title or "").strip() or Path(filename).stem})
|
||||
if progress_cb:
|
||||
progress_cb(99.0, "Finalizing PDF\u2026")
|
||||
tmp = Path(out_dir) / (filename + ".tmp") # atomic: write .tmp then rename
|
||||
with open(tmp, "wb") as out:
|
||||
writer.write(out)
|
||||
os.replace(tmp, Path(out_dir) / filename)
|
||||
return filename
|
||||
|
||||
|
||||
def report(progress_cb, pct, message):
|
||||
"""Progress callback with a message matching the UI regex \\((\\d+)/(\\d+)\\)\n when a count label is supplied, else a plain status line."""
|
||||
if progress_cb:
|
||||
progress_cb(pct, message)
|
||||
|
||||
|
||||
def convert_single(path: Path, job_id: str, *, build_html, title=None,
|
||||
css=PRE_CSS, progress_cb=None, output_dir=None,
|
||||
name_src: str | None = None) -> str:
|
||||
"""Common single-document pipeline: build_html() -> render -> finalize.
|
||||
|
||||
build_html() must return (html_body, page_label) where page_label feeds
|
||||
the UI progress line. Raising ValueError here becomes a job error with the
|
||||
same message the user sees.
|
||||
|
||||
name_src: original upload/CLI filename to derive the export name from
|
||||
(web uploads land on disk as {job_id}<ext>, so path.name is unusable
|
||||
there); defaults to path.name (CLI mode).
|
||||
"""
|
||||
out_dir = default_output_dir(output_dir)
|
||||
body, label = build_html()
|
||||
report(progress_cb, 30.0, f"Rendering {label} (1/1)")
|
||||
pdf_bytes = html_to_pdf_bytes(body, css)
|
||||
filename = derive_export_name(name_src or path.name, out_dir, title=title)
|
||||
return finalize_pdf(pdf_bytes, out_dir, filename, job_id,
|
||||
progress_cb=progress_cb, title=title)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 ""
|
||||
header, body = rows[0], rows[1:]
|
||||
head = "".join(f"<th>{common.escape(c)}</th>" for c in header)
|
||||
out = [f"<table><tr>{head}</tr>"]
|
||||
for row in body:
|
||||
# pad ragged rows to header width so borders line up
|
||||
cells = row + [""] * (len(header) - len(row))
|
||||
out.append("<tr>" + "".join(f"<td>{common.escape(c)}</td>" for c in cells) + "</tr>")
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""HTML / HTM -> PDF.
|
||||
|
||||
A standalone .html upload is rendered as-is: the document's own <style>/
|
||||
<link> rules stay authoritative. app.BASE_CSS is injected at the TOP of
|
||||
<head> as a fallback layer (equal-specificity rules lose to the document's
|
||||
own later declarations, same layering as the EPUB path). Fragments without
|
||||
a <head> ("<p>...</p>") render bare.
|
||||
|
||||
Declared charset is honored (windows-1252/latin-1 <meta charset> or XML
|
||||
declaration) via the pinned regexes in app — no mojibake. The document
|
||||
<title> becomes the export name and PDF /Title when present, else the
|
||||
file stem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from . import common
|
||||
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _extract_title(html_text: str) -> str | None:
|
||||
m = _TITLE_RE.search(html_text)
|
||||
if not m:
|
||||
return None
|
||||
title = re.sub(r"<[^>]+>", "", m.group(1))
|
||||
title = " ".join(title.split())
|
||||
return title or None
|
||||
|
||||
|
||||
def _inject_base_css(html_text: str) -> str:
|
||||
"""Insert BASE_CSS as a fallback <style> at the top of <head>.
|
||||
|
||||
Placed before the document's own styles so, at equal specificity,
|
||||
the document wins (WeasyPrint resolves source order within one
|
||||
stylesheet context like a browser). Fragments without <head> are
|
||||
returned unchanged (a lone <style> outside <head> is legal HTML5
|
||||
and still applies).
|
||||
"""
|
||||
import app # lazy: BASE_CSS lives with the EPUB pipeline
|
||||
|
||||
style = f"<style>\n{app.BASE_CSS}\n</style>"
|
||||
m = re.search(r"<head[^>]*>", 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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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: <reason>". Valid documents are
|
||||
pretty-printed (indent=2, original key order) into <pre> 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"<html><body><pre>{common.escape(pretty)}</pre></body></html>", 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,
|
||||
)
|
||||
@@ -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 <head> 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"<!DOCTYPE html><html><head><style>\n{app.BASE_CSS}\n</style>"
|
||||
f"</head><body>{body_html}</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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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. "<svg><g></path>") is a *job* error with
|
||||
"Invalid SVG: <reason>", 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 = (
|
||||
"<!DOCTYPE html><html><head><style>"
|
||||
"@page { size: A4; margin: 20mm; }"
|
||||
"body { margin: 0; font-kerning: none; }"
|
||||
"svg { max-width: 100%; height: auto; }"
|
||||
"</style></head><body>{svg}<div style='page-break-after: always'></div>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -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 <pre> 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"<html><body><pre>{common.escape(text)}</pre></body></html>", 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,
|
||||
)
|
||||
@@ -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: <reason>" (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
|
||||
<pre> 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"<html><body><pre>{common.escape(body)}</pre></body></html>", 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,
|
||||
)
|
||||
Reference in New Issue
Block a user