import json import re from dataclasses import dataclass, field from typing import Any from docx import Document from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn from docx.shared import Pt from docx.table import Table from docx.text.paragraph import Paragraph from jinja2 import Environment, meta JINJA_VAR_PATTERN = re.compile(r"\{\{[^{}]*?\}\}") JINJA_BLOCK_PATTERN = re.compile(r"\{%[^{}]*?%\}") TABLE_ROW_LOOP_PATTERN = re.compile( r"\{%\s*tr\s+for\s+(\w+)\s+in\s+(\w+)\s*%\}", re.IGNORECASE ) @dataclass class ParsedVariable: name: str label: str field_type: str order: int parent_variable: str | None = None style_params: dict[str, Any] = field(default_factory=dict) default_value: str | None = None is_required: bool = True def _extract_run_style(run) -> dict[str, Any]: style: dict[str, Any] = {} if run.font.name: style["font_name"] = run.font.name if run.font.size: style["font_size"] = run.font.size.pt if run.bold: style["bold"] = True if run.italic: style["italic"] = True if run.underline: style["underline"] = True if run.font.color and run.font.color.rgb: style["color"] = str(run.font.color.rgb) return style def _alignment_to_str(alignment) -> str | None: if alignment is None: return None mapping = { WD_ALIGN_PARAGRAPH.LEFT: "left", WD_ALIGN_PARAGRAPH.CENTER: "center", WD_ALIGN_PARAGRAPH.RIGHT: "right", WD_ALIGN_PARAGRAPH.JUSTIFY: "justify", } return mapping.get(alignment) def _get_paragraph_alignment_str(paragraph: Paragraph) -> str | None: """Read paragraph alignment, including Word values like 'start'/'end'.""" try: alignment = paragraph.alignment if alignment is not None: return _alignment_to_str(alignment) except ValueError: pass p_pr = paragraph._p.pPr if p_pr is not None: jc = p_pr.find(qn("w:jc")) if jc is not None: val = jc.get(qn("w:val")) xml_map = { "left": "left", "right": "right", "center": "center", "both": "justify", "justify": "justify", "start": "left", "end": "right", "distribute": "justify", } return xml_map.get(val) return None def _extract_paragraph_style(paragraph: Paragraph) -> dict[str, Any]: style: dict[str, Any] = {} alignment = _get_paragraph_alignment_str(paragraph) if alignment is not None: style["alignment"] = alignment if paragraph.runs: run_style = _extract_run_style(paragraph.runs[0]) style.update(run_style) return style def _extract_cell_style(cell) -> dict[str, Any]: style: dict[str, Any] = {} if cell.paragraphs: style.update(_extract_paragraph_style(cell.paragraphs[0])) if cell.width: style["width"] = cell.width.pt if hasattr(cell.width, "pt") else None tc = cell._tc tc_pr = tc.tcPr if tc_pr is not None: shd = tc_pr.find(qn("w:shd")) if shd is not None and shd.get(qn("w:fill")): style["background_color"] = shd.get(qn("w:fill")) return style def _extract_table_style(table: Table) -> dict[str, Any]: style: dict[str, Any] = {"rows": len(table.rows), "cols": len(table.columns)} if table.rows: style["row_styles"] = [ [_extract_cell_style(cell) for cell in row.cells] for row in table.rows ] return style def _get_text_from_element(element) -> str: if isinstance(element, Paragraph): return element.text if isinstance(element, Table): parts = [] for row in element.rows: for cell in row.cells: for p in cell.paragraphs: parts.append(p.text) return "\n".join(parts) return "" DOTTED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\.(\w+)\s*\}\}") def _find_dotted_variables_in_text( text: str, table_loops: dict[str, str] ) -> list[tuple[str, str, str]]: """Return list of (field_name, parent_list_var, loop_item_var).""" results: list[tuple[str, str, str]] = [] for match in DOTTED_VAR_PATTERN.finditer(text): item_var, field_name = match.groups() parent = table_loops.get(item_var) if parent: results.append((field_name, parent, item_var)) return results MALFORMED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}?") def _find_jinja_variables_in_text(text: str) -> list[str]: names: set[str] = set() env = Environment() try: ast = env.parse(text) names.update(meta.find_undeclared_variables(ast)) except Exception: pass names.update(re.findall(r"\{\{\s*(\w+)\s*\}\}", text)) names.update(MALFORMED_VAR_PATTERN.findall(text)) return sorted(names) def _detect_field_type(var_name: str, text: str, in_table: bool, is_loop_item: bool) -> str: if is_loop_item: return "table_cell" if in_table: return "table_cell" lower_text = text.lower() if any(kw in lower_text for kw in ["description", "comment", "notes", "textarea"]): return "textarea" if any(kw in var_name.lower() for kw in ["date", "дата"]): return "date" if any(kw in var_name.lower() for kw in ["amount", "price", "sum", "qty", "count", "number"]): return "number" return "text" def _humanize(name: str) -> str: return name.replace("_", " ").replace("-", " ").title() def parse_docx_template(file_path: str) -> list[ParsedVariable]: doc = Document(file_path) variables: list[ParsedVariable] = [] seen: set[str] = set() order = 0 table_loops: dict[str, str] = {} full_text_parts: list[str] = [] for element in doc.element.body: tag = element.tag.split("}")[-1] if tag == "p": para = Paragraph(element, doc) full_text_parts.append(para.text) text = para.text for match in TABLE_ROW_LOOP_PATTERN.finditer(text): item_var, list_var = match.groups() table_loops[item_var] = list_var elif tag == "tbl": table = Table(element, doc) for row in table.rows: for cell in row.cells: for p in cell.paragraphs: full_text_parts.append(p.text) text = p.text for match in TABLE_ROW_LOOP_PATTERN.finditer(text): item_var, list_var = match.groups() table_loops[item_var] = list_var full_text = "\n".join(full_text_parts) dotted_fields: dict[str, tuple[str, str]] = {} for text_part in full_text_parts: for field_name, parent, item_var in _find_dotted_variables_in_text( text_part, table_loops ): key = f"{parent}.{field_name}" if key not in dotted_fields: dotted_fields[key] = (field_name, parent) for list_var in sorted(set(table_loops.values())): if list_var not in seen: seen.add(list_var) variables.append( ParsedVariable( name=list_var, label=_humanize(list_var), field_type="table_row", order=order, style_params={"is_repeating_table": True}, ) ) order += 1 for key, (field_name, parent) in sorted(dotted_fields.items()): if key not in seen: seen.add(key) table_style = _find_table_style_for_variable(doc, field_name) field_type = _detect_field_type(field_name, field_name, True, True) variables.append( ParsedVariable( name=field_name, label=_humanize(field_name), field_type=field_type, order=order, parent_variable=parent, style_params=table_style, ) ) order += 1 for item_var in table_loops: seen.add(item_var) for element in doc.element.body: tag = element.tag.split("}")[-1] in_table = tag == "tbl" if tag == "p": para = Paragraph(element, doc) text = para.text var_names = _find_jinja_variables_in_text(text) para_style = _extract_paragraph_style(para) for var_name in var_names: if var_name in seen or var_name in table_loops: continue seen.add(var_name) field_type = _detect_field_type(var_name, text, False, False) variables.append( ParsedVariable( name=var_name, label=_humanize(var_name), field_type=field_type, order=order, style_params=para_style, ) ) order += 1 elif tag == "tbl": table = Table(element, doc) table_style = _extract_table_style(table) for row in table.rows: for cell in row.cells: for p in cell.paragraphs: text = p.text var_names = _find_jinja_variables_in_text(text) cell_style = _extract_cell_style(cell) cell_style["table_style"] = table_style for var_name in var_names: if var_name in seen or var_name in table_loops: continue if any( f"{table_loops[iv]}.{fn}" in seen for iv, fn in [ (m.group(1), m.group(2)) for m in DOTTED_VAR_PATTERN.finditer(text) ] if iv in table_loops ): continue seen.add(var_name) field_type = _detect_field_type(var_name, text, True, False) variables.append( ParsedVariable( name=var_name, label=_humanize(var_name), field_type=field_type, order=order, style_params=cell_style, ) ) order += 1 env = Environment() try: ast = env.parse(full_text) all_vars = meta.find_undeclared_variables(ast) for var_name in sorted(all_vars): if var_name not in seen: seen.add(var_name) variables.append( ParsedVariable( name=var_name, label=_humanize(var_name), field_type="text", order=order, ) ) order += 1 except Exception: pass return variables def _find_table_style_for_variable(doc: Document, var_name: str) -> dict[str, Any]: for element in doc.element.body: tag = element.tag.split("}")[-1] if tag != "tbl": continue table = Table(element, doc) for row in table.rows: for cell in row.cells: for p in cell.paragraphs: if var_name in p.text or f".{var_name}" in p.text: cell_style = _extract_cell_style(cell) cell_style["table_style"] = _extract_table_style(table) return cell_style return {} def variables_to_json(variables: list[ParsedVariable]) -> list[dict[str, Any]]: return [ { "name": v.name, "label": v.label, "field_type": v.field_type, "order": v.order, "parent_variable": v.parent_variable, "style_params": v.style_params, "default_value": v.default_value, "is_required": v.is_required, } for v in variables ]