378 lines
12 KiB
Python
378 lines
12 KiB
Python
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
|
|
]
|