feat: expand report export functionality to support multiple formats (PDF, DOCX, HTML, EPUB, etc.) and enhance UI for format selection

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-03-09 18:31:14 -07:00
parent ddb070bca8
commit 11b84a3fb4
10 changed files with 5375 additions and 4302 deletions

View file

@ -0,0 +1,323 @@
"""
Helpers for report export templates.
* ``get_typst_template_path()`` - path to the custom Pandoc -> Typst template.
* ``get_html_css_path()`` - path to the CSS stylesheet for HTML exports.
* ``get_reference_docx_path()`` - path to a styled reference.docx for Pandoc.
* ``get_reference_pptx_path()`` - path to a styled reference.pptx for Pandoc.
The reference DOCX is generated lazily on first call from Pandoc's built-in
default, then restyled with *python-docx* and cached on disk so subsequent
exports are instant.
"""
from __future__ import annotations
import subprocess
import threading
from pathlib import Path
_DIR = Path(__file__).resolve().parent
_GENERATED_DIR = _DIR / "_generated"
_REFERENCE_DOCX = _GENERATED_DIR / "reference.docx"
_REFERENCE_PPTX = _GENERATED_DIR / "reference.pptx"
_TYPST_TEMPLATE = _DIR / "report_pdf.typst"
_HTML_CSS = _DIR / "report_html.css"
_docx_lock = threading.Lock()
_pptx_lock = threading.Lock()
def get_typst_template_path() -> Path:
return _TYPST_TEMPLATE
def get_html_css_path() -> Path:
return _HTML_CSS
def get_reference_pptx_path() -> Path:
"""Return path to the styled reference.pptx, creating it if absent."""
if _REFERENCE_PPTX.exists():
return _REFERENCE_PPTX
with _pptx_lock:
if _REFERENCE_PPTX.exists():
return _REFERENCE_PPTX
_generate_reference_pptx()
return _REFERENCE_PPTX
def get_reference_docx_path() -> Path:
"""Return path to the styled reference.docx, creating it if absent."""
if _REFERENCE_DOCX.exists():
return _REFERENCE_DOCX
with _docx_lock:
if _REFERENCE_DOCX.exists():
return _REFERENCE_DOCX
_generate_reference_docx()
return _REFERENCE_DOCX
# ---------------------------------------------------------------------------
# Reference DOCX generation
# ---------------------------------------------------------------------------
_HEADING_COLOR_RGB = (0x1E, 0x29, 0x3B) # Slate-900
_ACCENT_RGB = (0x25, 0x63, 0xEB) # Blue-600
def _generate_reference_docx() -> None:
"""Build a professional reference.docx from Pandoc's default + restyling."""
import pypandoc
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Inches, Pt, RGBColor
_GENERATED_DIR.mkdir(parents=True, exist_ok=True)
# Step 1 - extract Pandoc's built-in reference.docx (contains all the
# style names that Pandoc maps its output to).
pandoc_bin = pypandoc.get_pandoc_path()
result = subprocess.run(
[pandoc_bin, "--print-default-data-file", "reference.docx"],
capture_output=True,
check=True,
)
_REFERENCE_DOCX.write_bytes(result.stdout)
# Step 2 - open and restyle
doc = Document(str(_REFERENCE_DOCX))
heading_color = RGBColor(*_HEADING_COLOR_RGB)
accent_color = RGBColor(*_ACCENT_RGB)
# -- Page setup ----------------------------------------------------------
for section in doc.sections:
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
_add_page_number_footer(section)
# -- Restyle existing styles ---------------------------------------------
heading_sizes = {1: 24, 2: 18, 3: 14, 4: 12, 5: 11, 6: 11}
for style in doc.styles:
name = style.name or ""
# Normal / body text
if name in ("Normal", "Body Text", "First Paragraph"):
style.font.name = "Calibri"
style.font.size = Pt(11)
pf = style.paragraph_format
pf.space_after = Pt(6)
pf.space_before = Pt(0)
pf.line_spacing = 1.15
if name == "First Paragraph":
pf.space_before = Pt(2)
# Headings 1-6
elif name.startswith("Heading") and name[-1:].isdigit():
level = int(name[-1])
style.font.name = "Calibri"
style.font.bold = True
style.font.color.rgb = heading_color
style.font.size = Pt(heading_sizes.get(level, 11))
pf = style.paragraph_format
pf.space_before = Pt(18 if level <= 2 else 12)
pf.space_after = Pt(6)
pf.keep_with_next = True
if level >= 4:
style.font.bold = False
style.font.italic = True
# Source Code (code blocks)
elif name == "Source Code":
style.font.name = "Consolas"
style.font.size = Pt(9.5)
pf = style.paragraph_format
pf.space_before = Pt(4)
pf.space_after = Pt(4)
pf.line_spacing = 1.0
_set_paragraph_shading(pf, "F8FAFC")
# Verbatim Char (inline code)
elif name == "Verbatim Char":
style.font.name = "Consolas"
style.font.size = Pt(9.5)
# Block Text (block quotes)
elif name == "Block Text":
style.font.italic = True
style.font.color.rgb = RGBColor(0x64, 0x74, 0x8B)
pf = style.paragraph_format
pf.left_indent = Inches(0.4)
pf.space_before = Pt(6)
pf.space_after = Pt(6)
# Hyperlink
elif name == "Hyperlink":
style.font.color.rgb = accent_color
style.font.underline = True
# Compact (tight lists)
elif name == "Compact":
style.font.name = "Calibri"
style.font.size = Pt(11)
if style.paragraph_format:
style.paragraph_format.space_after = Pt(2)
# Title
elif name == "Title":
style.font.name = "Calibri"
style.font.size = Pt(28)
style.font.bold = True
style.font.color.rgb = heading_color
pf = style.paragraph_format
pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf.space_after = Pt(4)
# Subtitle
elif name == "Subtitle":
style.font.name = "Calibri"
style.font.size = Pt(14)
style.font.color.rgb = RGBColor(0x64, 0x74, 0x8B)
pf = style.paragraph_format
pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf.space_after = Pt(12)
# Date
elif name == "Date":
style.font.name = "Calibri"
style.font.size = Pt(11)
style.font.color.rgb = RGBColor(0x64, 0x74, 0x8B)
pf = style.paragraph_format
pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
doc.save(str(_REFERENCE_DOCX))
# ---------------------------------------------------------------------------
# Reference PPTX generation
# ---------------------------------------------------------------------------
def _generate_reference_pptx() -> None:
"""Build a reference.pptx with smaller fonts for report-to-slide conversion."""
import pypandoc
from lxml import etree
_GENERATED_DIR.mkdir(parents=True, exist_ok=True)
pandoc_bin = pypandoc.get_pandoc_path()
result = subprocess.run(
[pandoc_bin, "--print-default-data-file", "reference.pptx"],
capture_output=True,
check=True,
)
_REFERENCE_PPTX.write_bytes(result.stdout)
from pptx import Presentation
prs = Presentation(str(_REFERENCE_PPTX))
master = prs.slide_masters[0]
ns = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
}
# Shrink body text: 24pt -> 16pt base, scaling down per level
body_sizes = [1600, 1400, 1300, 1200, 1100, 1100, 1100, 1100, 1100]
body_style = master._element.find(".//p:txStyles/p:bodyStyle", ns)
if body_style is not None:
for lvl_el in body_style:
tag = etree.QName(lvl_el).localname
if tag.startswith("lvl") and tag.endswith("pPr"):
idx = int(tag[3]) - 1
def_rpr = lvl_el.find("a:defRPr", ns)
if def_rpr is not None and idx < len(body_sizes):
def_rpr.set("sz", str(body_sizes[idx]))
# Shrink title: 33pt -> 26pt
title_style = master._element.find(".//p:txStyles/p:titleStyle", ns)
if title_style is not None:
for lvl_el in title_style:
def_rpr = lvl_el.find("a:defRPr", ns)
if def_rpr is not None:
def_rpr.set("sz", "2600")
prs.save(str(_REFERENCE_PPTX))
# ---------------------------------------------------------------------------
# OOXML helpers
# ---------------------------------------------------------------------------
def _set_paragraph_shading(paragraph_format, hex_color: str) -> None:
"""Apply background shading to a paragraph style via raw OOXML."""
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
ppr = paragraph_format._element if hasattr(paragraph_format, "_element") else None
if ppr is None:
return
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), hex_color)
ppr.append(shd)
def _add_page_number_footer(section) -> None:
"""Add a centered page number to the section footer via OOXML."""
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
footer = section.footer
footer.is_linked_to_previous = False
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
p.alignment = 1 # CENTER
run = p.add_run()
rpr = OxmlElement("w:rPr")
rsz = OxmlElement("w:sz")
rsz.set(qn("w:val"), "18") # 9pt
rpr.append(rsz)
rcolor = OxmlElement("w:color")
rcolor.set(qn("w:val"), "64748B")
rpr.append(rcolor)
run._element.append(rpr)
run.add_text("Page ")
fld_char_begin = OxmlElement("w:fldChar")
fld_char_begin.set(qn("w:fldCharType"), "begin")
run._element.append(fld_char_begin)
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = " PAGE "
run._element.append(instr)
fld_char_end = OxmlElement("w:fldChar")
fld_char_end.set(qn("w:fldCharType"), "end")
run._element.append(fld_char_end)
run.add_text(" of ")
fld_char_begin2 = OxmlElement("w:fldChar")
fld_char_begin2.set(qn("w:fldCharType"), "begin")
run._element.append(fld_char_begin2)
instr2 = OxmlElement("w:instrText")
instr2.set(qn("xml:space"), "preserve")
instr2.text = " NUMPAGES "
run._element.append(instr2)
fld_char_end2 = OxmlElement("w:fldChar")
fld_char_end2.set(qn("w:fldCharType"), "end")
run._element.append(fld_char_end2)