Files
iistwin/server/scripts/docx_form_controls.py
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

242 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""
Утилиты для подстановки переменных в DOCX-шаблоны.
Запуск:
python3 docx_form_controls.py replace input.docx output.docx key1=value1 key2=value2 [--tables tables.json]
"""
import argparse
import copy
import json
import os
import re
import shutil
import sys
import zipfile
from xml.sax.saxutils import escape
from docx import Document
from docx.shared import Pt
VARIABLE_RE = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
def _replace_in_xml(xml_path: str, values: dict[str, str]):
"""Заменяет {{variable}} на значения в XML-файле DOCX."""
with open(xml_path, "r", encoding="utf-8") as f:
text = f.read()
def repl(match: re.Match) -> str:
key = match.group(1)
value = values.get(key, match.group(0))
return escape(value, {"'": "'", '"': """})
text = VARIABLE_RE.sub(repl, text)
with open(xml_path, "w", encoding="utf-8") as f:
f.write(text)
def _replace_text_in_doc(doc: Document, values: dict[str, str]):
"""Заменяет {{variable}} на значения во всех параграфах и таблицах документа."""
placeholder_re = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
for paragraph in doc.paragraphs:
text = paragraph.text
if not placeholder_re.search(text):
continue
new_text = placeholder_re.sub(lambda m: values.get(m.group(1), m.group(0)), text)
paragraph.clear()
run = paragraph.add_run()
run.text = new_text
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
text = paragraph.text
if not placeholder_re.search(text):
continue
new_text = placeholder_re.sub(lambda m: values.get(m.group(1), m.group(0)), text)
paragraph.clear()
run = paragraph.add_run()
run.text = new_text
def _insert_tables(doc: Document, tables: dict[str, dict]):
"""Вставляет таблицы вместо плейсхолдеров {{table_code}}."""
paragraphs = list(doc.paragraphs)
for paragraph in paragraphs:
text = paragraph.text.strip()
match = VARIABLE_RE.fullmatch(text)
if not match:
continue
code = match.group(1)
table_data = tables.get(code)
if not table_data:
continue
columns = table_data.get("columns", [])
rows = table_data.get("rows", [])
paragraph.clear()
if not columns:
paragraph.add_run("[пустая таблица]")
continue
table = doc.add_table(rows=1, cols=len(columns))
table.style = "Table Grid"
hdr_cells = table.rows[0].cells
for i, col in enumerate(columns):
hdr_cells[i].text = str(col.get("name", ""))
for paragraph in hdr_cells[i].paragraphs:
for run in paragraph.runs:
run.font.bold = True
for row_data in rows:
row_cells = table.add_row().cells
for i, col in enumerate(columns):
col_id = col.get("id")
value = ""
if isinstance(row_data, dict) and col_id is not None:
value = row_data.get(col_id, "")
row_cells[i].text = str(value) if value is not None else ""
# Перемещаем таблицу на место параграфа
tbl_element = copy.deepcopy(table._tbl)
paragraph._element.addnext(tbl_element)
def replace_variables(
input_path: str,
output_path: str,
values: dict[str, str],
tables: dict[str, dict] | None = None,
assets: dict[str, dict] | None = None,
):
"""Подменяет переменные {{var}} в DOCX на значения."""
if tables or assets:
# Используем python-docx для точечной вставки таблиц и изображений
doc = Document(input_path)
_replace_text_in_doc(doc, values)
if tables:
_insert_tables(doc, tables)
if assets:
_insert_assets(doc, assets)
doc.save(output_path)
return
tmp_dir = f"{output_path}.tmp"
if os.path.exists(tmp_dir):
shutil.rmtree(tmp_dir)
with zipfile.ZipFile(input_path, "r") as zip_in:
zip_in.extractall(tmp_dir)
xml_path = os.path.join(tmp_dir, "word", "document.xml")
if os.path.exists(xml_path):
_replace_in_xml(xml_path, values)
# Также заменяем в header/footer
for subdir in ("word",):
sub_path = os.path.join(tmp_dir, subdir)
if not os.path.isdir(sub_path):
continue
for fname in os.listdir(sub_path):
if fname.startswith(("header", "footer")) and fname.endswith(".xml"):
_replace_in_xml(os.path.join(sub_path, fname), values)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zip_out:
for root, _, files in os.walk(tmp_dir):
for file in files:
full_path = os.path.join(root, file)
arcname = os.path.relpath(full_path, tmp_dir)
zip_out.write(full_path, arcname)
shutil.rmtree(tmp_dir)
def _insert_assets(doc: Document, assets: dict[str, dict]):
"""Вставляет изображения вместо плейсхолдеров {{asset_code}}."""
from docx.shared import Cm
paragraphs = list(doc.paragraphs)
for paragraph in paragraphs:
text = paragraph.text.strip()
match = VARIABLE_RE.fullmatch(text)
if not match:
continue
code = match.group(1)
asset = assets.get(code)
if not asset:
continue
paragraph.clear()
asset_type = asset.get("type", "image")
if asset_type != "image":
paragraph.add_run("[неподдерживаемый актив]")
continue
asset_path = asset.get("path")
if not asset_path or not os.path.exists(asset_path):
paragraph.add_run("[изображение не найдено]")
continue
try:
run = paragraph.add_run()
picture = run.add_picture(asset_path)
width_cm = asset.get("width")
height_cm = asset.get("height")
if width_cm:
picture.width = Cm(float(width_cm))
if height_cm:
picture.height = Cm(float(height_cm))
elif width_cm and picture.width:
# Сохраняем пропорции
aspect = picture.height / picture.width if picture.width else 1
picture.height = int(picture.width * aspect)
except Exception as e:
paragraph.add_run(f"[ошибка вставки изображения: {e}]")
def main():
parser = argparse.ArgumentParser(description="DOCX variable substitution")
subparsers = parser.add_subparsers(dest="command", required=True)
replace_parser = subparsers.add_parser("replace", help="replace {{var}} with values")
replace_parser.add_argument("input", help="input DOCX")
replace_parser.add_argument("output", help="output DOCX")
replace_parser.add_argument("values", nargs="*", help="key=value pairs")
replace_parser.add_argument("--tables", dest="tables_path", help="JSON file with table variables")
replace_parser.add_argument("--assets", dest="assets_path", help="JSON file with asset variables")
args = parser.parse_args()
if args.command == "replace":
values = {}
for pair in args.values or []:
if "=" not in pair:
print(f"Invalid pair: {pair}", file=sys.stderr)
sys.exit(1)
key, value = pair.split("=", 1)
values[key] = value
tables = None
if args.tables_path:
with open(args.tables_path, "r", encoding="utf-8") as f:
tables = json.load(f)
assets = None
if args.assets_path:
with open(args.assets_path, "r", encoding="utf-8") as f:
assets = json.load(f)
replace_variables(args.input, args.output, values, tables, assets)
print(f"Replaced: {args.output}")
if __name__ == "__main__":
main()