Files
Document_editor/backend/app/services/docx_renderer.py
2026-06-15 09:18:58 +03:00

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}"
)