"""DOCX template generator with dynamic placeholders and repeating rows.""" from __future__ import annotations import copy from dataclasses import asdict from pathlib import Path from typing import Any from ..models.tender_data import TenderData def _stringify(value: Any) -> str: if value is None: return "" if isinstance(value, bool): return "Yes" if value else "No" if isinstance(value, float): return f"{value:g}" return str(value) def _build_replacements(td: TenderData) -> dict[str, str]: data = asdict(td) replacements = { "{{" + key.upper() + "}}": _stringify(value) for key, value in data.items() if not isinstance(value, (list, dict)) } replacements.update({ "{{TENDER_SECURITY_WORDS}}": td.tender_security_amount_words, "{{TENDER_SECURITY_NUMERIC}}": td.tender_security_bdt, }) return replacements def _merge_runs_in_paragraph(paragraph) -> None: if len(paragraph.runs) <= 1: return full_text = "".join(run.text for run in paragraph.runs) paragraph.runs[0].text = full_text for run in paragraph.runs[1:]: run.text = "" def _replace_in_paragraph(paragraph, replacements: dict[str, str]) -> None: _merge_runs_in_paragraph(paragraph) for run in paragraph.runs: for token, value in replacements.items(): if token in run.text: run.text = run.text.replace(token, value) def _text_nodes(row): return row._tr.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t") def _fill_row(row_xml, values: list[str]): nodes = [node for node in _text_nodes(type("Row", (), {"_tr": row_xml})()) if node.text and node.text.strip()] for idx, node in enumerate(nodes): if idx < len(values): node.text = _stringify(values[idx]) return row_xml def _equipment_rows(template_row, td: TenderData): rows = [] for item in td.equipment: row_xml = copy.deepcopy(template_row._tr) rows.append(_fill_row(row_xml, [item.sl_no, item.equipment_type, item.minimum_number])) return rows def _manpower_rows(template_row, td: TenderData): rows = [] for item in td.manpower: row_xml = copy.deepcopy(template_row._tr) rows.append(_fill_row(row_xml, [ item.sl_no, item.post, item.qualification, item.nos, item.total_exp, item.similar_exp, ])) return rows def _jv_partner_rows(template_row, td: TenderData): rows = [] for partner in td.jv_partners: row_xml = copy.deepcopy(template_row._tr) share = f"{partner.share_percent:g}%" if partner.share_words: share += f" ({partner.share_words})" rows.append(_fill_row(row_xml, [ partner.name, partner.signatory_name, partner.position, partner.role, share, partner.address, ])) return rows def _replace_table_markers(table, replacements: dict[str, str], td: TenderData) -> None: from docx.oxml.ns import qn markers = { "{{EQUIPMENT_ROWS}}": _equipment_rows, "{{MANPOWER_ROWS}}": _manpower_rows, "{{JV_PARTNER_ROWS}}": _jv_partner_rows, } table_xml = table._tbl original_rows = list(table.rows) for idx, row in reversed(list(enumerate(original_rows))): row_text = " ".join(cell.text for cell in row.cells) matched = next((marker for marker in markers if marker in row_text), None) if not matched: continue new_rows = markers[matched](row, td) ref = row._tr insert_at = list(table_xml).index(ref) table_xml.remove(ref) for row_xml in reversed(new_rows): table_xml.insert(insert_at, row_xml) for row in table.rows: for cell in row.cells: for paragraph in cell.paragraphs: _replace_in_paragraph(paragraph, replacements) def fill_docx_template(template_path: str, output_path: str, td: TenderData) -> str: try: from docx import Document except ImportError as exc: raise ImportError("python-docx is required. Run: pip install python-docx") from exc doc = Document(template_path) replacements = _build_replacements(td) for paragraph in doc.paragraphs: _replace_in_paragraph(paragraph, replacements) for table in doc.tables: _replace_table_markers(table, replacements, td) for section in doc.sections: headers = [section.header, section.first_page_header, section.even_page_header] footers = [section.footer, section.first_page_footer, section.even_page_footer] for part in headers + footers: for paragraph in part.paragraphs: _replace_in_paragraph(paragraph, replacements) for table in part.tables: _replace_table_markers(table, replacements, td) Path(output_path).parent.mkdir(parents=True, exist_ok=True) doc.save(output_path) print(f" [OK] Generated: {Path(output_path).name}") return output_path