354 lines
12 KiB
Python
354 lines
12 KiB
Python
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from html import escape
|
|
from typing import Any
|
|
|
|
from docx import Document
|
|
from docx.oxml.ns import qn
|
|
from docx.shared import Pt, RGBColor
|
|
from docx.table import Table, _Cell
|
|
from docxtpl import DocxTemplate
|
|
|
|
from app.services.docx_parser import _get_paragraph_alignment_str
|
|
|
|
TABLE_ROW_LOOP_PATTERN = re.compile(
|
|
r"\{%\s*tr\s+for\s+(\w+)\s+in\s+(\w+)\s*%\}", re.IGNORECASE
|
|
)
|
|
TABLE_ROW_END_PATTERN = re.compile(r"\{%\s*tr\s+endfor\s*%\}", re.IGNORECASE)
|
|
DOTTED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\.(\w+)\s*\}\}")
|
|
SIMPLE_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
|
TOLERANT_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}?")
|
|
|
|
|
|
def _prepare_context(field_data: dict[str, Any]) -> dict[str, Any]:
|
|
return dict(field_data)
|
|
|
|
|
|
def _clean_jinja_markers(text: str) -> str:
|
|
text = TABLE_ROW_LOOP_PATTERN.sub("", text)
|
|
text = TABLE_ROW_END_PATTERN.sub("", text)
|
|
return text.strip()
|
|
|
|
|
|
def _replace_variables_in_text(text: str, context: dict[str, Any]) -> str:
|
|
def replacer(match: re.Match) -> str:
|
|
name = match.group(1)
|
|
value = context.get(name, "")
|
|
return str(value) if value is not None else ""
|
|
|
|
text = SIMPLE_VAR_PATTERN.sub(replacer, text)
|
|
if "{{" in text:
|
|
text = TOLERANT_VAR_PATTERN.sub(replacer, text)
|
|
return text
|
|
|
|
|
|
def _replace_simple_vars(text: str, context: dict[str, Any]) -> str:
|
|
return _replace_variables_in_text(text, context)
|
|
|
|
|
|
def _replace_vars_in_paragraph(paragraph, context: dict[str, Any]) -> None:
|
|
"""Replace Jinja variables while preserving run formatting."""
|
|
if "{{" not in paragraph.text:
|
|
return
|
|
|
|
full_text = "".join(run.text for run in paragraph.runs)
|
|
new_text = _replace_variables_in_text(full_text, context)
|
|
if new_text == full_text:
|
|
return
|
|
|
|
if paragraph.runs:
|
|
paragraph.runs[0].text = new_text
|
|
for run in paragraph.runs[1:]:
|
|
run.text = ""
|
|
else:
|
|
paragraph.add_run(new_text)
|
|
|
|
|
|
def _expand_table_rows(doc: Document, context: dict[str, Any]) -> None:
|
|
for table in doc.tables:
|
|
rows_to_process: list[tuple[int, str, str, list[str]]] = []
|
|
|
|
for row_idx, row in enumerate(table.rows):
|
|
row_text = " ".join(
|
|
p.text for cell in row.cells for p in cell.paragraphs
|
|
)
|
|
loop_match = TABLE_ROW_LOOP_PATTERN.search(row_text)
|
|
if not loop_match:
|
|
continue
|
|
|
|
item_var, list_var = loop_match.groups()
|
|
field_names: list[str] = []
|
|
for cell in row.cells:
|
|
for p in cell.paragraphs:
|
|
for m in DOTTED_VAR_PATTERN.finditer(p.text):
|
|
if m.group(1) == item_var:
|
|
field = m.group(2)
|
|
if field not in field_names:
|
|
field_names.append(field)
|
|
|
|
rows_to_process.append((row_idx, item_var, list_var, field_names))
|
|
|
|
for row_idx, item_var, list_var, field_names in reversed(rows_to_process):
|
|
template_row = table.rows[row_idx]
|
|
items = context.get(list_var, [])
|
|
if not isinstance(items, list):
|
|
items = []
|
|
|
|
if not items:
|
|
for cell in template_row.cells:
|
|
for p in cell.paragraphs:
|
|
_replace_vars_in_paragraph(p, context)
|
|
cleaned = _clean_jinja_markers(p.text)
|
|
if cleaned != p.text and p.runs:
|
|
p.runs[0].text = cleaned
|
|
for run in p.runs[1:]:
|
|
run.text = ""
|
|
continue
|
|
|
|
template_cells = [
|
|
[_clean_jinja_markers(p.text) for p in cell.paragraphs]
|
|
for cell in template_row.cells
|
|
]
|
|
|
|
for item in items:
|
|
new_row = table.add_row()
|
|
for cell_idx, cell in enumerate(new_row.cells):
|
|
if cell_idx < len(template_cells):
|
|
template_text = (
|
|
template_cells[cell_idx][0]
|
|
if template_cells[cell_idx]
|
|
else ""
|
|
)
|
|
result = template_text
|
|
for field in field_names:
|
|
placeholder = f"{{{{ {item_var}.{field} }}}}"
|
|
value = ""
|
|
if isinstance(item, dict):
|
|
value = item.get(field, "")
|
|
result = result.replace(placeholder, str(value))
|
|
result = DOTTED_VAR_PATTERN.sub(
|
|
lambda m, it=item: str(it.get(m.group(2), ""))
|
|
if isinstance(it, dict) and m.group(1) == item_var
|
|
else m.group(0),
|
|
result,
|
|
)
|
|
if cell.paragraphs:
|
|
_replace_vars_in_paragraph(cell.paragraphs[0], context)
|
|
if cell.paragraphs[0].text != result and cell.paragraphs[0].runs:
|
|
cell.paragraphs[0].runs[0].text = result
|
|
for run in cell.paragraphs[0].runs[1:]:
|
|
run.text = ""
|
|
else:
|
|
cell.text = result
|
|
|
|
tbl = table._tbl
|
|
tr = template_row._tr
|
|
tbl.remove(tr)
|
|
|
|
|
|
def _replace_paragraph_vars(doc: Document, context: dict[str, Any]) -> None:
|
|
for para in doc.paragraphs:
|
|
_replace_vars_in_paragraph(para, context)
|
|
|
|
for table in doc.tables:
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
for para in cell.paragraphs:
|
|
if "{{" in para.text and not DOTTED_VAR_PATTERN.search(para.text):
|
|
_replace_vars_in_paragraph(para, context)
|
|
|
|
|
|
def render_docx(template_path: str, field_data: dict[str, Any], output_path: str) -> str:
|
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
context = _prepare_context(field_data)
|
|
|
|
try:
|
|
tpl = DocxTemplate(template_path)
|
|
tpl.render(context)
|
|
tpl.save(output_path)
|
|
return output_path
|
|
except Exception:
|
|
doc = Document(template_path)
|
|
_expand_table_rows(doc, context)
|
|
_replace_paragraph_vars(doc, context)
|
|
doc.save(output_path)
|
|
return output_path
|
|
|
|
|
|
def _apply_style_to_run(run, style: dict[str, Any]) -> None:
|
|
if style.get("font_name"):
|
|
run.font.name = style["font_name"]
|
|
if style.get("font_size"):
|
|
run.font.size = Pt(style["font_size"])
|
|
if style.get("bold"):
|
|
run.bold = True
|
|
if style.get("italic"):
|
|
run.italic = True
|
|
if style.get("underline"):
|
|
run.underline = True
|
|
if style.get("color"):
|
|
try:
|
|
run.font.color.rgb = RGBColor.from_string(style["color"])
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _run_to_html(run) -> str:
|
|
text = escape(run.text)
|
|
if not text:
|
|
return ""
|
|
styles: list[str] = []
|
|
if run.bold:
|
|
styles.append("font-weight:bold")
|
|
if run.italic:
|
|
styles.append("font-style:italic")
|
|
if run.underline:
|
|
styles.append("text-decoration:underline")
|
|
if run.font.size:
|
|
styles.append(f"font-size:{run.font.size.pt}pt")
|
|
if run.font.name:
|
|
styles.append(f"font-family:'{run.font.name}'")
|
|
if styles:
|
|
return f'<span style="{";".join(styles)}">{text}</span>'
|
|
return text
|
|
|
|
|
|
def _paragraph_to_html(paragraph) -> str:
|
|
parts = [_run_to_html(run) for run in paragraph.runs]
|
|
return "".join(parts) if any(parts) else escape(paragraph.text)
|
|
|
|
|
|
def _get_cell_colspan(tc) -> int:
|
|
tc_pr = tc.tcPr
|
|
if tc_pr is None:
|
|
return 1
|
|
grid_span = tc_pr.find(qn("w:gridSpan"))
|
|
if grid_span is not None:
|
|
return int(grid_span.get(qn("w:val"), 1))
|
|
return 1
|
|
|
|
|
|
def _get_vmerge_state(tc) -> str | None:
|
|
tc_pr = tc.tcPr
|
|
if tc_pr is None:
|
|
return None
|
|
v_merge = tc_pr.find(qn("w:vMerge"))
|
|
if v_merge is None:
|
|
return None
|
|
return v_merge.get(qn("w:val")) or "continue"
|
|
|
|
|
|
def _cell_to_html(cell: _Cell) -> str:
|
|
return "<br>".join(_paragraph_to_html(p) for p in cell.paragraphs)
|
|
|
|
|
|
def _table_to_html(table: Table) -> str:
|
|
"""Render table using actual w:tc elements (handles merged cells)."""
|
|
rows_html: list[str] = []
|
|
|
|
for row in table.rows:
|
|
tr = row._tr
|
|
cells_html: list[str] = []
|
|
|
|
for tc in tr.tc_lst:
|
|
if _get_vmerge_state(tc) == "continue":
|
|
continue
|
|
|
|
cell = _Cell(tc, table)
|
|
colspan = _get_cell_colspan(tc)
|
|
attrs = ""
|
|
if colspan > 1:
|
|
attrs += f' colspan="{colspan}"'
|
|
|
|
cells_html.append(f"<td{attrs}>{_cell_to_html(cell)}</td>")
|
|
|
|
rows_html.append(f"<tr>{''.join(cells_html)}</tr>")
|
|
|
|
return (
|
|
'<table class="doc-table" border="1" cellpadding="4" cellspacing="0">'
|
|
+ "".join(rows_html)
|
|
+ "</table>"
|
|
)
|
|
|
|
|
|
def docx_to_html(file_path: str) -> str:
|
|
doc = Document(file_path)
|
|
html_parts = ['<div class="doc-preview">']
|
|
|
|
for element in doc.element.body:
|
|
tag = element.tag.split("}")[-1]
|
|
if tag == "p":
|
|
from docx.text.paragraph import Paragraph
|
|
|
|
para = Paragraph(element, doc)
|
|
align = ""
|
|
alignment = _get_paragraph_alignment_str(para)
|
|
if alignment is not None:
|
|
align = f' style="text-align:{alignment}"'
|
|
text = _paragraph_to_html(para)
|
|
html_parts.append(f"<p{align}>{text}</p>")
|
|
elif tag == "tbl":
|
|
table = Table(element, doc)
|
|
html_parts.append(_table_to_html(table))
|
|
|
|
html_parts.append("</div>")
|
|
return "\n".join(html_parts)
|
|
|
|
|
|
def export_to_pdf(docx_path: str, output_path: str) -> str:
|
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"libreoffice",
|
|
"--headless",
|
|
"--convert-to",
|
|
"pdf",
|
|
"--outdir",
|
|
os.path.dirname(output_path),
|
|
docx_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
if result.returncode == 0:
|
|
base = os.path.splitext(os.path.basename(docx_path))[0]
|
|
generated = os.path.join(os.path.dirname(output_path), f"{base}.pdf")
|
|
if os.path.exists(generated) and generated != output_path:
|
|
os.rename(generated, output_path)
|
|
return output_path
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
pass
|
|
|
|
return _fallback_pdf_export(docx_path, output_path)
|
|
|
|
|
|
def _fallback_pdf_export(docx_path: str, output_path: str) -> str:
|
|
html = docx_to_html(docx_path)
|
|
full_html = f"""<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8">
|
|
<style>
|
|
body {{ font-family: 'Times New Roman', serif; margin: 40px; }}
|
|
.doc-table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
|
|
.doc-table td, .doc-table th {{ border: 1px solid #333; padding: 6px 10px; }}
|
|
p {{ margin: 6px 0; }}
|
|
</style></head><body>{html}</body></html>"""
|
|
|
|
try:
|
|
import weasyprint
|
|
|
|
weasyprint.HTML(string=full_html).write_pdf(output_path)
|
|
return output_path
|
|
except ImportError:
|
|
html_path = output_path.replace(".pdf", ".html")
|
|
with open(html_path, "w", encoding="utf-8") as f:
|
|
f.write(full_html)
|
|
raise RuntimeError(
|
|
"PDF export requires LibreOffice (libreoffice) or weasyprint. "
|
|
f"HTML preview saved to {html_path}"
|
|
)
|