fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
241
server/scripts/docx_form_controls.py
Normal file
241
server/scripts/docx_form_controls.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user