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'{text}' 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 "
".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"{_cell_to_html(cell)}") rows_html.append(f"{''.join(cells_html)}") return ( '' + "".join(rows_html) + "
" ) def docx_to_html(file_path: str) -> str: doc = Document(file_path) html_parts = ['
'] 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"{text}

") elif tag == "tbl": table = Table(element, doc) html_parts.append(_table_to_html(table)) html_parts.append("
") 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""" {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}" )