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

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
]