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:
sirdave73
2026-09-02 01:53:24 -04:00
parent 0b575a3e1f
commit 345c39417b
17 changed files with 1306 additions and 17 deletions
+85
View File
@@ -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)
+188
View File
@@ -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)
+71
View File
@@ -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,
)
+67
View File
@@ -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,
)
+53
View File
@@ -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)
+38
View File
@@ -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,
)
+54
View File
@@ -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,
)
+66
View File
@@ -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)
+55
View File
@@ -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)
+55
View File
@@ -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,
)
+51
View File
@@ -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,
)