659 lines
21 KiB
Python
659 lines
21 KiB
Python
"""
|
||
Email sender module for ScrAIbe.
|
||
|
||
Sends transcription outputs (TXT, JSON, etc.) via SMTP.
|
||
All credentials are configured via environment variables.
|
||
Supports both plain text and HTML email bodies.
|
||
Template placeholders are primarily filled via environment variables.
|
||
"""
|
||
|
||
import base64
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import smtplib
|
||
from email import encoders
|
||
from email.mime.base import MIMEBase
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from docx import Document
|
||
from docx.oxml import OxmlElement
|
||
from docx.oxml.ns import qn
|
||
from docx.shared import Inches, Pt
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
|
||
logger = logging.getLogger("scraibe.email_sender")
|
||
|
||
|
||
class EmailError(Exception):
|
||
pass
|
||
|
||
|
||
def get_email_config():
|
||
"""
|
||
Read email configuration from environment variables.
|
||
Raises EmailError if required fields are missing.
|
||
"""
|
||
smtp_host = os.getenv("EMAIL_SMTP_HOST")
|
||
smtp_port = os.getenv("EMAIL_SMTP_PORT")
|
||
smtp_user = os.getenv("EMAIL_SMTP_USER")
|
||
smtp_password = os.getenv("EMAIL_SMTP_PASSWORD")
|
||
from_address = os.getenv("EMAIL_FROM_ADDRESS")
|
||
use_tls_str = os.getenv("EMAIL_SMTP_USE_TLS", "true").strip().lower()
|
||
use_tls = use_tls_str not in ("false", "0", "no")
|
||
|
||
if not all([smtp_host, smtp_port, smtp_user, smtp_password, from_address]):
|
||
raise EmailError(
|
||
"Email configuration incomplete. "
|
||
"Ensure EMAIL_SMTP_HOST, EMAIL_SMTP_PORT, EMAIL_SMTP_USER, "
|
||
"EMAIL_SMTP_PASSWORD, and EMAIL_FROM_ADDRESS are set."
|
||
)
|
||
|
||
return {
|
||
"smtp_host": smtp_host,
|
||
"smtp_port": int(smtp_port),
|
||
"smtp_user": smtp_user,
|
||
"smtp_password": smtp_password,
|
||
"from_address": from_address,
|
||
"use_tls": use_tls,
|
||
}
|
||
|
||
|
||
def _load_css(path: str) -> str:
|
||
"""
|
||
Load CSS file content if it exists.
|
||
"""
|
||
if not path or not os.path.exists(path):
|
||
return ""
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
|
||
def _email_logo_html() -> str:
|
||
"""
|
||
Return a subtle watermark-style logo for emails.
|
||
|
||
- Priority:
|
||
1) EMAIL_LOGO_URL (direct URL)
|
||
2) EMAIL_LOGO_PATH (local file as base64)
|
||
- Style: small, faint, bottom-right, non-intrusive.
|
||
"""
|
||
logo_url = os.getenv("EMAIL_LOGO_URL")
|
||
src = logo_url
|
||
|
||
if not logo_url:
|
||
logo_path = os.getenv("EMAIL_LOGO_PATH", "/app/src/misc/logo1.png")
|
||
if os.path.exists(logo_path):
|
||
try:
|
||
with open(logo_path, "rb") as f:
|
||
b64 = base64.b64encode(f.read()).decode("utf-8")
|
||
src = f"data:image/png;base64,{b64}"
|
||
except Exception:
|
||
src = None
|
||
|
||
if not src:
|
||
return ""
|
||
|
||
# Watermark: bottom-right, low opacity, compact
|
||
return (
|
||
f'<div style="text-align: right; margin-top: 24px; opacity: 0.15;">'
|
||
f'<img src="{src}" alt="Logo" style="max-width: 90px; height: auto; display: inline-block;" />'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
def _accent_color() -> str:
|
||
"""
|
||
Accent color for UI and emails.
|
||
Default: #7C6DA0
|
||
"""
|
||
return os.getenv("EMAIL_ACCENT_COLOR", "#7C6DA0")
|
||
|
||
|
||
def build_template_context(**runtime_kwargs: Any) -> Dict[str, Any]:
|
||
"""
|
||
Build a context dict for templates from:
|
||
- environment variables (base, customizable)
|
||
- runtime-provided values (override env if present)
|
||
|
||
Environment variables:
|
||
- EMAIL_CONTACT_ADDRESS: value for {contact_email}
|
||
- EMAIL_CSS_PATH: path to mail_style.css (optional; we inline it)
|
||
- EMAIL_LOGO_URL: URL for email logo (preferred)
|
||
- EMAIL_LOGO_PATH: fallback local path for email logo
|
||
- EMAIL_ACCENT_COLOR: accent color (default #7C6DA0)
|
||
"""
|
||
# Load and inline mail_style.css for consistent email styling
|
||
css_path = os.getenv("EMAIL_CSS_PATH", "/app/src/misc/mail_style.css")
|
||
css_text = _load_css(css_path)
|
||
|
||
# Build logo HTML (URL or local fallback)
|
||
logo_html = _email_logo_html()
|
||
|
||
# Accent color
|
||
accent = _accent_color()
|
||
|
||
ctx: Dict[str, Any] = {
|
||
"contact_email": os.getenv("EMAIL_CONTACT_ADDRESS", "support@example.com"),
|
||
"email_css": css_text,
|
||
"email_logo": logo_html,
|
||
"accent_color": accent,
|
||
}
|
||
|
||
# Runtime values override env if provided
|
||
if runtime_kwargs:
|
||
ctx.update(runtime_kwargs)
|
||
|
||
return ctx
|
||
|
||
|
||
def load_template(template_name: str, **runtime_kwargs: Any) -> str:
|
||
"""
|
||
Load an HTML email template from misc/ and render placeholders.
|
||
|
||
Expects files like:
|
||
/app/src/misc/upload_notification_template.html
|
||
/app/src/misc/success_template.html
|
||
/app/src/misc/error_notification_template.html
|
||
"""
|
||
base = os.getenv("SCRAIBE_TEMPLATES_DIR", "/app/src/misc")
|
||
path = os.path.join(base, template_name)
|
||
|
||
if not os.path.exists(path):
|
||
raise EmailError(f"Email template not found: {path}")
|
||
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
template = f.read()
|
||
|
||
# Build context from env + runtime
|
||
ctx = build_template_context(**runtime_kwargs)
|
||
|
||
# Replace {placeholder} style variables safely
|
||
try:
|
||
return template.format(**ctx)
|
||
except KeyError as e:
|
||
raise EmailError(f"Missing template variable: {e}")
|
||
|
||
|
||
def send_email(
|
||
to: str,
|
||
subject: str,
|
||
body: str,
|
||
html: Optional[str],
|
||
attachments: List[str],
|
||
cc: Optional[str] = None,
|
||
) -> bool:
|
||
"""
|
||
Send an email with optional HTML body and file attachments.
|
||
|
||
Args:
|
||
to: Comma-separated list of recipient email addresses.
|
||
subject: Email subject.
|
||
body: Email body (plain text).
|
||
html: Email body (HTML), or None.
|
||
attachments: List of file paths to attach.
|
||
cc: Comma-separated list of CC email addresses (optional).
|
||
|
||
Returns:
|
||
True if sent successfully.
|
||
|
||
Raises:
|
||
EmailError if sending fails.
|
||
"""
|
||
try:
|
||
cfg = get_email_config()
|
||
except EmailError as e:
|
||
logger.error("Email configuration error: %s", e)
|
||
raise
|
||
|
||
# Parse recipients
|
||
to_list = [addr.strip() for addr in to.split(",") if addr.strip()]
|
||
cc_list = [addr.strip() for addr in cc.split(",") if addr.strip()] if cc else []
|
||
|
||
if not to_list:
|
||
raise EmailError("No valid 'To' email addresses provided.")
|
||
|
||
# Ensure subject is never blank
|
||
if not subject or not subject.strip():
|
||
logger.warning("Subject was blank or missing; using default subject.")
|
||
subject = "ScrAIbe: Your transcript is ready"
|
||
|
||
subject = subject.strip()
|
||
|
||
has_attachments = bool(attachments)
|
||
|
||
# Build the text/HTML part (alternative)
|
||
alt = MIMEMultipart("alternative")
|
||
alt.attach(MIMEText(body, "plain"))
|
||
if html:
|
||
alt.attach(MIMEText(html, "html"))
|
||
|
||
if has_attachments:
|
||
# Outer message: multipart/mixed with headers
|
||
msg = MIMEMultipart("mixed")
|
||
msg["From"] = cfg["from_address"]
|
||
msg["To"] = ", ".join(to_list)
|
||
if cc_list:
|
||
msg["Cc"] = ", ".join(cc_list)
|
||
msg["Subject"] = subject
|
||
|
||
# Attach the alternative (text/HTML) part
|
||
msg.attach(alt)
|
||
|
||
# Attach files
|
||
for file_path in attachments:
|
||
if not os.path.isfile(file_path):
|
||
logger.warning("Attachment file not found, skipping: %s", file_path)
|
||
continue
|
||
|
||
try:
|
||
with open(file_path, "rb") as f:
|
||
part = MIMEBase("application", "octet-stream")
|
||
part.set_payload(f.read())
|
||
encoders.encode_base64(part)
|
||
part.add_header(
|
||
"Content-Disposition",
|
||
"attachment",
|
||
filename=os.path.basename(file_path),
|
||
)
|
||
msg.attach(part)
|
||
except Exception as e:
|
||
logger.warning("Failed to attach file %s: %s", file_path, e)
|
||
else:
|
||
# No attachments: use the alternative part as the root message
|
||
msg = alt
|
||
msg["From"] = cfg["from_address"]
|
||
msg["To"] = ", ".join(to_list)
|
||
if cc_list:
|
||
msg["Cc"] = ", ".join(cc_list)
|
||
msg["Subject"] = subject
|
||
|
||
# Connect and send
|
||
try:
|
||
if cfg["use_tls"]:
|
||
server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=30)
|
||
server.ehlo()
|
||
server.starttls()
|
||
server.ehlo()
|
||
else:
|
||
server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=30)
|
||
server.ehlo()
|
||
|
||
server.login(cfg["smtp_user"], cfg["smtp_password"])
|
||
server.sendmail(
|
||
cfg["from_address"],
|
||
to_list + cc_list,
|
||
msg.as_string(),
|
||
)
|
||
server.quit()
|
||
logger.info(
|
||
"Email sent to %s (CC: %s) with subject: %s",
|
||
to_list,
|
||
cc_list or "None",
|
||
subject,
|
||
)
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error("Failed to send email: %s", e)
|
||
raise EmailError(f"Failed to send email: {e}")
|
||
|
||
|
||
# ------------ DOCX helpers ------------
|
||
|
||
# Namespaces
|
||
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||
|
||
|
||
def _set_element_attr(elem, attr, value):
|
||
elem.set(f"{{{W_NS}}}{attr}", str(value))
|
||
|
||
|
||
def _create_transcript_section_properties(section):
|
||
"""
|
||
Configure the section properties for transcript DOCX:
|
||
- Margins: 1 inch all sides
|
||
- Single column layout
|
||
- No built-in line numbering (we embed line numbers as text for portability)
|
||
- Remove document grid to avoid off-by-one line numbering
|
||
"""
|
||
sectPr = section._sectPr
|
||
|
||
# Margins: 1 inch = 1440 twips
|
||
pgMar = sectPr.find(f"{{{W_NS}}}pgMar")
|
||
if pgMar is None:
|
||
pgMar = OxmlElement("w:pgMar")
|
||
sectPr.append(pgMar)
|
||
_set_element_attr(pgMar, "top", "1440")
|
||
_set_element_attr(pgMar, "right", "1440")
|
||
_set_element_attr(pgMar, "bottom", "1440")
|
||
_set_element_attr(pgMar, "left", "1440")
|
||
_set_element_attr(pgMar, "header", "720")
|
||
_set_element_attr(pgMar, "footer", "720")
|
||
_set_element_attr(pgMar, "gutter", "0")
|
||
|
||
# Ensure single column (no multi-column layout)
|
||
cols = sectPr.find(f"{{{W_NS}}}cols")
|
||
if cols is not None:
|
||
_set_element_attr(cols, "num", "1")
|
||
_set_element_attr(cols, "space", "720")
|
||
|
||
# Remove document grid entirely
|
||
for docGrid in sectPr.findall(f"{{{W_NS}}}docGrid"):
|
||
sectPr.remove(docGrid)
|
||
|
||
# Remove any built-in line numbering; we will use text-based line numbers
|
||
for lnNumType in sectPr.findall(f"{{{W_NS}}}lnNumType"):
|
||
sectPr.remove(lnNumType)
|
||
|
||
|
||
def _add_transcript_paragraph(doc, line_text, line_number):
|
||
"""
|
||
Add a single transcript line as a paragraph with an embedded line number.
|
||
Uses a left tab stop so the line number appears in the left margin area,
|
||
independent of built-in line numbering, ensuring consistent behavior
|
||
across Word, LibreOffice, Google Docs, etc.
|
||
"""
|
||
line_text = line_text.strip()
|
||
if not line_text:
|
||
return
|
||
|
||
p = doc.add_paragraph()
|
||
|
||
# Set up paragraph formatting:
|
||
# - No left indent; we control spacing via tab stop
|
||
# - Single line spacing, no extra before/after
|
||
pPr = p._p.get_or_add_pPr()
|
||
|
||
# Remove any default indent
|
||
pPr.find(f"{{{W_NS}}}ind") and pPr.remove(pPr.find(f"{{{W_NS}}}ind"))
|
||
|
||
# Define a left tab stop for line numbers (e.g. 360 twips ≈ 0.25")
|
||
tabs = OxmlElement("w:tabs")
|
||
tab = OxmlElement("w:tab")
|
||
tab.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val", "left")
|
||
tab.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pos", "360")
|
||
tabs.append(tab)
|
||
pPr.append(tabs)
|
||
|
||
spacing = OxmlElement("w:spacing")
|
||
_set_element_attr(spacing, "before", "0")
|
||
_set_element_attr(spacing, "after", "0")
|
||
_set_element_attr(spacing, "line", "360") # 1.5 line spacing (12pt * 1.5 = 18pt → 360 twips)
|
||
_set_element_attr(spacing, "lineRule", "auto")
|
||
pPr.append(spacing)
|
||
|
||
# Try to match: [00:00] SPEAKER 1: content
|
||
m = re.match(r"\[(\d+:\d+(?::\d+)?)\]\s*(.+?):\s*(.*)", line_text)
|
||
|
||
# Line number run (no underline)
|
||
run_ln = p.add_run(str(line_number))
|
||
run_ln.font.name = "Courier"
|
||
run_ln.font.size = Pt(12)
|
||
run_ln.underline = False
|
||
|
||
# Tab + spaces between line number and content
|
||
# - 2 base spaces + 7 more for first line of speaker turn
|
||
# - 2 base spaces + 3 more for continuation lines
|
||
if m:
|
||
extra_spaces = " " # 7 spaces for speaker lines
|
||
else:
|
||
extra_spaces = " " # 3 spaces for continuation lines
|
||
|
||
run_tab = p.add_run("\t " + extra_spaces)
|
||
run_tab.font.name = "Courier"
|
||
run_tab.font.size = Pt(12)
|
||
run_tab.underline = False
|
||
|
||
if m:
|
||
ts, speaker, content = m.groups()
|
||
label_text = f"[{ts}] {speaker.upper()}:"
|
||
|
||
# Label run (underline)
|
||
run_label = p.add_run(label_text)
|
||
run_label.underline = True
|
||
run_label.font.name = "Courier"
|
||
run_label.font.size = Pt(12)
|
||
|
||
# Space run (no underline)
|
||
run_space = p.add_run(" ")
|
||
run_space.underline = False
|
||
run_space.font.name = "Courier"
|
||
run_space.font.size = Pt(12)
|
||
|
||
# Content run (no underline)
|
||
run_txt = p.add_run(content.strip())
|
||
run_txt.underline = False
|
||
run_txt.font.name = "Courier"
|
||
run_txt.font.size = Pt(12)
|
||
else:
|
||
# Non-standard line: plain text
|
||
run = p.add_run(line_text)
|
||
run.underline = False
|
||
run.font.name = "Courier"
|
||
run.font.size = Pt(12)
|
||
|
||
|
||
# ------------ Public DOCX functions ------------
|
||
|
||
def create_transcript_docx(text: str, filename: str, summary_text: str = ""):
|
||
"""
|
||
Create a transcript DOCX with:
|
||
- 1" margins on all sides
|
||
- 12pt Courier font
|
||
- Each page has exactly 29 numbered lines of text
|
||
- Max 60 characters per line (including number and spaces)
|
||
- Words preserved (no clipping or omission)
|
||
- Blank spacing between number and text preserved
|
||
- Page break after every 29 lines
|
||
- Centered footer: "X of Y"
|
||
- If summary_text is provided:
|
||
- Page break after transcript
|
||
- Centered "SUMMARY" title
|
||
- Summary content (markdown-aware)
|
||
"""
|
||
from . import docx_styles
|
||
|
||
# Step 1: Prepare transcript into pages of 29 lines each
|
||
prepared_pages = []
|
||
current_page = []
|
||
line_count = 0
|
||
|
||
# 52 chars content + 2 digits + 1 tab + 9 spaces = 64 max
|
||
MAX_CONTENT_LEN = 52
|
||
|
||
for raw_line in text.strip().splitlines():
|
||
raw_line = raw_line.strip()
|
||
if not raw_line:
|
||
continue
|
||
|
||
# Wrap into segments without clipping words
|
||
words = raw_line.split()
|
||
segments = []
|
||
current = ""
|
||
for w in words:
|
||
if not current:
|
||
current = w
|
||
elif len(current) + 1 + len(w) <= MAX_CONTENT_LEN:
|
||
current += " " + w
|
||
else:
|
||
segments.append(current)
|
||
current = w
|
||
if current:
|
||
segments.append(current)
|
||
|
||
# Add segments to pages, enforcing 29 lines per page
|
||
for seg in segments:
|
||
if line_count == 29:
|
||
prepared_pages.append(current_page)
|
||
current_page = []
|
||
line_count = 0
|
||
current_page.append(seg)
|
||
line_count += 1
|
||
|
||
if current_page:
|
||
prepared_pages.append(current_page)
|
||
|
||
# Step 2: Create DOCX
|
||
doc = Document()
|
||
style = doc.styles["Normal"]
|
||
style.font.name = "Courier"
|
||
style.font.size = Pt(12)
|
||
|
||
body = doc.element.body
|
||
for p in list(body.findall(f"{{{W_NS}}}p")):
|
||
body.remove(p)
|
||
|
||
_create_transcript_section_properties(doc.sections[0])
|
||
|
||
# Step 3: Optionally add cover page
|
||
from . import docx_cover
|
||
from datetime import datetime
|
||
cover_enabled = os.getenv("COVER_PAGE_ENABLED", "false").strip().lower() in ("true", "1", "yes")
|
||
if cover_enabled:
|
||
date_str = datetime.utcnow().strftime("%B %d, %Y")
|
||
docx_cover.add_cover_page(
|
||
doc,
|
||
title="TRANSCRIPT",
|
||
date_str=date_str,
|
||
)
|
||
|
||
# Step 4: Write prepared pages into DOCX
|
||
for page_idx, page_lines in enumerate(prepared_pages):
|
||
# Write each line with its number (1-30)
|
||
for line_num, line_text in enumerate(page_lines, start=1):
|
||
p = doc.add_paragraph()
|
||
_add_transcript_paragraph(doc, line_text, line_number=line_num)
|
||
# Remove the extra paragraph added by add_paragraph (we already added runs)
|
||
# _add_transcript_paragraph already creates its own paragraph, so we need to avoid duplication.
|
||
# Correct approach: _add_transcript_paragraph should add to doc, not p.
|
||
# To avoid breaking existing behavior, keep _add_transcript_paragraph as-is
|
||
# and remove the temporary paragraph we just added.
|
||
# This is an internal fix to ensure page break logic is clean.
|
||
# We'll rely on _add_transcript_paragraph creating its own paragraph.
|
||
# To avoid an extra blank paragraph, we remove p if it has no runs.
|
||
if not p.runs:
|
||
body.remove(p._p)
|
||
|
||
# After each page except the last, add a page break via next paragraph
|
||
if page_idx < len(prepared_pages) - 1:
|
||
p_break = doc.add_paragraph()
|
||
p_break.paragraph_format.page_break_before = True
|
||
# Ensure it’s empty and doesn’t show as a visible line
|
||
if p_break.runs:
|
||
for r in p_break.runs:
|
||
r.text = ""
|
||
# Remove any formatting that would show as a visible line
|
||
pPr = p_break._p.get_or_add_pPr()
|
||
for child in list(pPr):
|
||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||
if tag in ("tabs", "spacing", "ind"):
|
||
pPr.remove(child)
|
||
spacing = OxmlElement("w:spacing")
|
||
_set_element_attr(spacing, "before", "0")
|
||
_set_element_attr(spacing, "after", "0")
|
||
_set_element_attr(spacing, "line", "240")
|
||
_set_element_attr(spacing, "lineRule", "auto")
|
||
pPr.append(spacing)
|
||
|
||
# Step 5: If summary_text provided, append it with a hard page break
|
||
if summary_text and summary_text.strip():
|
||
# Hard page break after transcript
|
||
p_break = doc.add_paragraph()
|
||
p_break.paragraph_format.page_break_before = True
|
||
|
||
# Centered "SUMMARY" title
|
||
title_p = doc.add_paragraph()
|
||
title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
run = title_p.add_run("SUMMARY")
|
||
run.font.name = "Courier"
|
||
run.font.size = Pt(18)
|
||
run.bold = True
|
||
|
||
# Add summary content using markdown-aware styling
|
||
docx_styles.parse_simple_md_to_paragraphs(doc, summary_text.strip())
|
||
|
||
# Step 6: Add footer: "X of Y" centered
|
||
section = doc.sections[0]
|
||
footer = section.footer
|
||
footer.is_linked_to_previous = False
|
||
footer_para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
|
||
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
|
||
for r in footer_para.runs:
|
||
r.text = ""
|
||
|
||
def add_field(run, code):
|
||
fldChar = OxmlElement("w:fldChar")
|
||
fldChar.set(qn("w:fldCharType"), "begin")
|
||
run._r.append(fldChar)
|
||
|
||
instrText = OxmlElement("w:instrText")
|
||
instrText.set(qn("xml:space"), "preserve")
|
||
instrText.text = code
|
||
run._r.append(instrText)
|
||
|
||
fldCharEnd = OxmlElement("w:fldChar")
|
||
fldCharEnd.set(qn("w:fldCharType"), "end")
|
||
run._r.append(fldCharEnd)
|
||
|
||
run_page = footer_para.add_run()
|
||
add_field(run_page, " PAGE ")
|
||
|
||
run_of = footer_para.add_run(" of ")
|
||
|
||
run_total = footer_para.add_run()
|
||
add_field(run_total, " NUMPAGES ")
|
||
|
||
doc.save(filename)
|
||
|
||
|
||
def create_summary_docx(text: str, filename: str):
|
||
"""
|
||
Create a summary DOCX with:
|
||
- 1" margins on all sides
|
||
- 12pt Courier font
|
||
- Markdown-aware WYSIWYG styling (headings, bullets, bold/italic)
|
||
"""
|
||
from . import docx_styles
|
||
|
||
doc = Document()
|
||
|
||
# Base font
|
||
style = doc.styles["Normal"]
|
||
style.font.name = "Courier"
|
||
style.font.size = Pt(12)
|
||
|
||
# Margins: 1 inch all sides
|
||
for section in doc.sections:
|
||
section.left_margin = Inches(1.0)
|
||
section.right_margin = Inches(1.0)
|
||
section.top_margin = Inches(1.0)
|
||
section.bottom_margin = Inches(1.0)
|
||
|
||
# Remove default paragraph
|
||
body = doc.element.body
|
||
for p in list(body.findall(f"{{{W_NS}}}p")):
|
||
body.remove(p)
|
||
|
||
# Optionally add cover page
|
||
from . import docx_cover
|
||
from datetime import datetime
|
||
cover_enabled = os.getenv("COVER_PAGE_ENABLED", "false").strip().lower() in ("true", "1", "yes")
|
||
if cover_enabled:
|
||
date_str = datetime.utcnow().strftime("%B %d, %Y")
|
||
docx_cover.add_cover_page(
|
||
doc,
|
||
title="SUMMARY",
|
||
date_str=date_str,
|
||
)
|
||
|
||
# Add summary content using markdown-aware styling
|
||
if text.strip():
|
||
docx_styles.parse_simple_md_to_paragraphs(doc, text.strip())
|
||
|
||
doc.save(filename)
|