какой код лучше:
import os
import json
import traceback
from datetime import datetime
import subprocess

import pandas as pd
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.oxml.ns import qn, nsdecls
from docx.oxml import OxmlElement, parse_xml

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog

try:
    import win32com.client

    WIN32COM_AVAILABLE = True
except ImportError:
    WIN32COM_AVAILABLE = False

WORKDIR = os.getcwd()
TEMPLATES_DIR = os.path.join(WORKDIR, "templates")
CONFIG_DIR = os.path.join(WORKDIR, "config")
PROFILES_DIR = os.path.join(CONFIG_DIR, "profiles")

for d in (TEMPLATES_DIR, CONFIG_DIR, PROFILES_DIR):
    os.makedirs(d, exist_ok=True)

SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json")
PLACEHOLDERS_FILE = os.path.join(CONFIG_DIR, "placeholders.json")
MANDATORY_COLS = ['Объект', 'Свойство', 'Значение']


class Logger:
    def __init__(self):
        self.history = []

    def log(self, msg):
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        record = f"[{ts}] {msg}"
        self.history.append(record)
        print(record)
        if hasattr(app_ref, "txt_log") and app_ref.txt_log:
            app_ref.txt_log.config(state='normal')
            app_ref.txt_log.insert("end", record + "\n")
            app_ref.txt_log.see("end")
            app_ref.txt_log.config(state='disabled')

    def get_text(self):
        return "\n".join(self.history)

    def save_to_file(self, path):
        try:
            with open(path, 'w', encoding='utf-8') as f:
                f.write(self.get_text())
            self.log(f"Лог сохранён: {path}")
        except Exception as e:
            self.log(f"Ошибка сохранения лога: {e}")


logger = Logger()
app_ref = None  # Ссылка на приложение, для логирования в UI


class Settings:
    def __init__(self):
        self.data = {
            "table_style": {
                "header_bold": True,
                "alignment": "center",
                "font_name": "Calibri",
                "font_size": 11,
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            },
            "export": {
                "format": "docx",
                "pdf_password": "",
                "compress_images": True,
                "template": None
            }
        }
        self.load()

    def load(self):
        if os.path.exists(SETTINGS_FILE):
            try:
                with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                    d = json.load(f)
                self.data.update(d)
                exp = self.data.get('export', {})
                if 'format' not in exp:
                    exp['format'] = 'docx'
                if 'pdf_password' not in exp:
                    exp['pdf_password'] = ''
                if 'compress_images' not in exp:
                    exp['compress_images'] = True
                self.data['export'] = exp

                ts = self.data.get('table_style', {})
                defaults = {
                    "border_style": "тонкая",
                    "section_header_font_name": "Calibri",
                    "section_header_font_size": 12,
                    "section_header_bold": True,
                    "section_header_align": "center",
                    "section_header_bg_color": "FFFF00"
                }
                for k, v in defaults.items():
                    if k not in ts:
                        ts[k] = v
                self.data['table_style'] = ts

                logger.log("Настройки загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки настроек: {e}")

    def save(self):
        try:
            with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log("Настройки сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения настроек: {e}")

    def save_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log(f"Профиль сохранён: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка сохранения профиля: {e}")
            return False

    def load_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'r', encoding='utf-8') as f:
                d = json.load(f)
            self.data.update(d)

            ts = self.data.get('table_style', {})
            defaults = {
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            }
            for k, v in defaults.items():
                if k not in ts:
                    ts[k] = v
            self.data['table_style'] = ts

            logger.log(f"Профиль загружен: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка загрузки профиля: {e}")
            return False


settings = Settings()


class Placeholders:
    def __init__(self):
        self.placeholders = {}
        self.load()

    def load(self):
        if os.path.isfile(PLACEHOLDERS_FILE):
            try:
                with open(PLACEHOLDERS_FILE, 'r', encoding='utf-8') as f:
                    self.placeholders = json.load(f)
                logger.log("Плейсхолдеры загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки плейсхолдеров: {e}")

    def save(self):
        try:
            with open(PLACEHOLDERS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.placeholders, f, ensure_ascii=False, indent=4)
            logger.log("Плейсхолдеры сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения плейсхолдеров: {e}")


placeholders = Placeholders()


def extract_article_full(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        return obj[idx + 4:].strip()
    words = obj.strip().split()
    if len(words) <= 2:
        return obj.strip()
    return " ".join(words[-3:]).strip()


def extract_product_type(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        part = obj[:idx].strip()
        if part:
            return part
        return "Неизвестно"
    words = obj.strip().split()
    if len(words) <= 3:
        return " ".join(words)
    return " ".join(words[:3])


def extract_param(prop):
    if not isinstance(prop, str):
        return ""
    return prop.split("_")[-1].strip()


def load_and_transform_excel_grouped(path):
    logger.log(f"Загрузка файла: {path}")
    df = pd.read_excel(path, engine="openpyxl")
    missing = [c for c in MANDATORY_COLS if c not in df.columns]
    if missing:
        raise ValueError(f"Отсутствуют колонки: {missing}")

    df["Тип товара"] = df["Объект"].apply(extract_product_type)
    df["Артикул"] = df["Объект"].apply(extract_article_full)
    df["Параметр"] = df["Свойство"].apply(extract_param)
    df["Значение"] = df["Значение"].astype(str)
    df = df[(df["Артикул"] != "") & (df["Параметр"] != "")].reset_index(drop=True)

    grouped = {}
    for art, gdf in df.groupby("Артикул"):
        obj_full = gdf["Объект"].iloc[0]
        prod_type = gdf["Тип товара"].iloc[0]

        sections = []
        current_header = None
        current_params = []

        for _, row in gdf.iterrows():
            if row["Значение"].strip().lower() == "<раздел>":
                if current_header is not None or current_params:
                    sections.append({"header": current_header, "params": current_params})
                current_header = row["Параметр"]
                current_params = []
            else:
                current_params.append({"parameter": row["Параметр"], "value": row["Значение"]})

        if current_header is not None or current_params:
            sections.append({"header": current_header, "params": current_params})

        if not sections:
            sections = [
                {
                    "header": None,
                    "params": [{"parameter": r["Параметр"], "value": r["Значение"]} for _, r in gdf.iterrows()],
                }
            ]

        grouped[art] = {"object_full": obj_full, "product_type": prod_type, "sections": sections}
    logger.log(f"Данные сгруппированы по артикулам: {len(grouped)}")
    return grouped


def inline_replace_in_paragraph(paragraph, placeholder, replacement):
    runs = paragraph.runs
    full_text = "".join(run.text for run in runs)
    if placeholder not in full_text:
        return
    new_text = full_text.replace(placeholder, replacement)
    for run in runs:
        run.text = ""
    if runs:
        runs[0].text = new_text


def docx_replace_placeholder(doc, key, value):
    ph = "{{" + key + "}}"
    for p in doc.paragraphs:
        if ph in p.text:
            inline_replace_in_paragraph(p, ph, str(value))
    for tbl in doc.tables:
        for row in tbl.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    if ph in p.text:
                        inline_replace_in_paragraph(p, ph, str(value))
    replace_placeholder_in_header_footer(doc, key, value)


def replace_placeholder_in_header_footer(doc, key, value):
    ph = "{{" + key + "}}"
    for section in doc.sections:
        header = section.header
        for p in header.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in header.tables:
            for row in tbl.rows:
                for cell in row.cells:
                    for p in cell.paragraphs:
                        if ph in p.text:
                            inline_replace_in_paragraph(p, ph, str(value))
        footer = section.footer
        for p in footer.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in footer.tables:
            for row in tbl.rows:
                for cell in row.cells:
                    for p in cell.paragraphs:
                        if ph in p.text:
                            inline_replace_in_paragraph(p, ph, str(value))


def set_table_border(table, border_style):
    if border_style == 'нет':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="nil"/>
            <w:left w:val="nil"/>
            <w:bottom w:val="nil"/>
            <w:right w:val="nil"/>
            <w:insideH w:val="nil"/>
            <w:insideV w:val="nil"/>
        </w:tblBorders>
        """ % nsdecls('w')
    elif border_style == 'тонкая':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')
    elif border_style == 'толстая':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="12" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')
    else:
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')

    tblPr = table._element.tblPr
    for child in tblPr.findall("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tblBorders"):
        tblPr.remove(child)
    borders = parse_xml(border_xml)
    tblPr.append(borders)


def set_cell_background(cell, hex_color):
    tc = cell._tc
    tcPr = tc.get_or_add_tcPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:fill'), hex_color)
    tcPr.append(shd)


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        global app_ref
        app_ref = self

        self.title("Автоматизация техдокументации")
        self.geometry("1200x720")
        self.minsize(1000, 600)

        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)

        self.grouped_data = {}
        self.filtered_df = pd.DataFrame()
        self.filtered_df_backup = pd.DataFrame()
        self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])

        # Храним параметры и их возможные значения для выбора фильтров (множество значений для каждого параметра)
        self.all_params = []  # Список всех параметров
        self.param_values = {}  # { param_name: set(values) }

        self.user_placeholders = placeholders.placeholders.copy()
        self.templates = []
        self.current_template = None
        self.selected_profile = None

        self.filter_params_selected = {}  # {param_name: value} для нескольких параметров

        self.create_widgets()
        self.create_menu()
        self.bind_shortcuts()
        self.load_templates()
        self.load_profiles()
        self.load_placeholders_to_tree()
        self.load_settings_to_gui()

        self.create_placeholders_from_loaded_products()

        logger.log("Приложение запущено")

    def create_placeholders_from_loaded_products(self):
        if not self.grouped_data:
            logger.log("Данные товаров отсутствуют - автоматическое создание плейсхолдеров пропущено")
            return

        unique_params = set()
        for info in self.grouped_data.values():
            for section in info['sections']:
                for param in section['params']:
                    unique_params.add(param['parameter'])

        updated = False
        for param in unique_params:
            if param not in self.user_placeholders:
                self.user_placeholders[param] = "{{" + param + "}}"
                updated = True

        if updated:
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            logger.log(f"Автоматически создано плейсхолдеров из параметров: {len(unique_params)}")
        else:
            logger.log("Автоматическое создание плейсхолдеров: никаких новых плейсхолдеров не добавлено")

    def create_menu(self):
        menubar = tk.Menu(self)

        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Загрузить Excel", command=self.load_excel_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Сохранить лог...", command=self.export_log)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.quit)
        menubar.add_cascade(label="Файл", menu=file_menu)

        basket_menu = tk.Menu(menubar, tearoff=0)
        basket_menu.add_command(label="Удалить выделенные", command=self.remove_selected_from_basket)
        basket_menu.add_command(label="Очистить корзину", command=self.clear_basket)
        menubar.add_cascade(label="Корзина", menu=basket_menu)

        help_menu = tk.Menu(menubar, tearoff=0)
        help_menu.add_command(label="О программе", command=self.show_about)
        menubar.add_cascade(label="Помощь", menu=help_menu)

        self.config(menu=menubar)

    def bind_shortcuts(self):
        pass

    def show_about(self):
        messagebox.showinfo("О программе", "Автоматизация техдокументации\nВерсия 1.0")

    def create_widgets(self):
        self.nb = ttk.Notebook(self)
        self.nb.grid(row=0, column=0, sticky="nsew")

        # --- Вкладка данных ---
        tab_data = ttk.Frame(self.nb)
        self.nb.add(tab_data, text="Данные")

        tab_data.grid_rowconfigure(1, weight=1)
        tab_data.grid_columnconfigure(0, weight=1)
        tab_data.grid_columnconfigure(1, weight=0)

        filter_frame = ttk.Frame(tab_data)
        filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        filter_frame.grid_columnconfigure(7, weight=1)

        ttk.Label(filter_frame, text="Поиск по артикулу:").grid(row=0, column=0, sticky="w")
        self.var_quick_search = tk.StringVar()
        ent_quick_search = ttk.Entry(filter_frame, textvariable=self.var_quick_search)
        ent_quick_search.grid(row=0, column=1, sticky="ew", padx=3)
        filter_frame.grid_columnconfigure(1, weight=2)
        ent_quick_search.bind("<KeyRelease>", self.quick_search)

        ttk.Label(filter_frame, text="Фильтры параметров:").grid(row=0, column=2, sticky="w", padx=10)

        self.btn_add_param_filter = ttk.Button(filter_frame, text="+ Фильтр", command=self.add_param_filter_row)
        self.btn_add_param_filter.grid(row=0, column=7, sticky="e")

        self.param_filters_container = ttk.Frame(filter_frame)
        self.param_filters_container.grid(row=1, column=0, columnspan=8, sticky="ew", pady=5)
        self.param_filters_container.grid_columnconfigure(0, weight=1)

        self.param_filter_rows = []

        tbl_frame = ttk.Frame(tab_data)
        tbl_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        tbl_frame.grid_rowconfigure(0, weight=1)
        tbl_frame.grid_columnconfigure(0, weight=1)

        self.tree_data = ttk.Treeview(tbl_frame, show="headings", selectmode="extended")
        self.tree_data.grid(row=0, column=0, sticky="nsew")
        self.tree_data.bind("<Double-1>", self.add_selected_to_basket)
        self.tree_data.bind("<Button-1>", self.show_item_params)
        self.tree_data.bind("<Button-3>", self.context_menu_data)

        scr_y = ttk.Scrollbar(tbl_frame, orient="vertical", command=self.tree_data.yview)
        scr_y.grid(row=0, column=1, sticky="ns")
        scr_x = ttk.Scrollbar(tbl_frame, orient="horizontal", command=self.tree_data.xview)
        scr_x.grid(row=1, column=0, sticky="ew")
        self.tree_data.configure(yscrollcommand=scr_y.set, xscrollcommand=scr_x.set)

        right_frame = ttk.Frame(tab_data, relief="groove", borderwidth=2)
        right_frame.grid(row=1, column=1, sticky="nsew", padx=5, pady=5)
        tab_data.grid_columnconfigure(1, weight=0)
        right_frame.grid_rowconfigure(1, weight=1)
        right_frame.grid_columnconfigure(0, weight=1)

        ttk.Label(right_frame, text="Параметры выбранного товара:").grid(row=0, column=0, sticky="w", padx=5, pady=(0, 5))
        self.tree_params = ttk.Treeview(right_frame, show="headings", columns=["Параметр", "Значение"], height=20)
        self.tree_params.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.tree_params.heading("Параметр", text="Параметр")
        self.tree_params.heading("Значение", text="Значение")
        self.tree_params.column("Параметр", width=150, anchor="w")
        self.tree_params.column("Значение", width=150, anchor="w")

        sb_params_y = ttk.Scrollbar(right_frame, orient="vertical", command=self.tree_params.yview)
        sb_params_y.grid(row=1, column=1, sticky="ns")
        self.tree_params.configure(yscrollcommand=sb_params_y.set)

        basket_frame = ttk.LabelFrame(self, text="Корзина товаров")
        basket_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.grid_rowconfigure(1, weight=1)

        basket_frame.grid_rowconfigure(0, weight=1)
        basket_frame.grid_columnconfigure(0, weight=1)

        self.tree_basket = ttk.Treeview(basket_frame, show="headings", selectmode="extended", height=6)
        self.tree_basket.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
        self.tree_basket.bind("<Delete>", lambda e: self.remove_selected_from_basket())
        self.tree_basket.bind("<Button-3>", self.context_menu_basket)

        sb_basket_y = ttk.Scrollbar(basket_frame, orient="vertical", command=self.tree_basket.yview)
        sb_basket_y.grid(row=0, column=1, sticky="ns", pady=5)
        sb_basket_x = ttk.Scrollbar(basket_frame, orient="horizontal", command=self.tree_basket.xview)
        sb_basket_x.grid(row=1, column=0, sticky="ew", padx=5)
        self.tree_basket.configure(yscrollcommand=sb_basket_y.set, xscrollcommand=sb_basket_x.set)

        ctrl_basket = ttk.Frame(basket_frame)
        ctrl_basket.grid(row=2, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        ctrl_basket.grid_columnconfigure(3, weight=1)
        ttk.Button(ctrl_basket, text="Удалить выделенные", command=self.remove_selected_from_basket).grid(row=0, column=0, padx=5)
        ttk.Button(ctrl_basket, text="Очистить корзину", command=self.clear_basket).grid(row=0, column=1, padx=5)
        ttk.Button(ctrl_basket, text="Генерировать документ", command=self.generate_document).grid(row=0, column=2, padx=5)

        tab_set_gen = ttk.Frame(self.nb)
        self.nb.add(tab_set_gen, text="Настройки и Генерация")

        tab_set_gen.grid_columnconfigure(0, weight=1)
        tab_set_gen.grid_rowconfigure(6, weight=1)  # строка списка плейсхолдеров - тянется по высоте

        prof_frame = ttk.LabelFrame(tab_set_gen, text="Профили настроек")
        prof_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
        prof_frame.grid_columnconfigure(1, weight=1)

        self.combo_profiles = ttk.Combobox(prof_frame, state='readonly')
        self.combo_profiles.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
        self.combo_profiles.bind("<<ComboboxSelected>>", self.on_profile_change)

        btn_load_profile = ttk.Button(prof_frame, text="Загрузить профиль", command=self.load_profile)
        btn_load_profile.grid(row=0, column=1, padx=5)
        btn_save_profile = ttk.Button(prof_frame, text="Сохранить профиль", command=self.save_profile)
        btn_save_profile.grid(row=0, column=2, padx=5)
        btn_refresh_profiles = ttk.Button(prof_frame, text="Обновить список", command=self.load_profiles)
        btn_refresh_profiles.grid(row=0, column=3, padx=5)

        ttk.Label(tab_set_gen, text="Выбор шаблона:").grid(row=1, column=0, sticky='w', padx=10, pady=(0, 5))
        self.combo_templates = ttk.Combobox(tab_set_gen, state='readonly')
        self.combo_templates.grid(row=2, column=0, sticky="ew", padx=10)
        self.combo_templates.bind("<<ComboboxSelected>>", self.on_template_change)
        btn_preview_template = ttk.Button(tab_set_gen, text="Предпросмотр шаблона", command=self.preview_template)
        btn_preview_template.grid(row=3, column=0, sticky='w', padx=10, pady=5)

        ttk.Label(tab_set_gen, text="Плейсхолдеры:").grid(row=4, column=0, sticky='w', padx=10, pady=(5, 3))

        # Контейнер для поиска + списка с прокруткой по вертикали
        ph_container = ttk.Frame(tab_set_gen)
        ph_container.grid(row=5, column=0, sticky="nsew", padx=10)
        ph_container.grid_columnconfigure(0, weight=1)
        ph_container.grid_rowconfigure(1, weight=1)

        search_frame = ttk.Frame(ph_container)
        search_frame.grid(row=0, column=0, sticky="ew")
        search_frame.columnconfigure(1, weight=1)

        ttk.Label(search_frame, text="Поиск:").grid(row=0, column=0, sticky="w")
        self.var_placeholder_search = tk.StringVar()
        ent_ph_search = ttk.Entry(search_frame, textvariable=self.var_placeholder_search)
        ent_ph_search.grid(row=0, column=1, sticky="ew")
        ent_ph_search.bind("<KeyRelease>", self.on_placeholder_search)

        tree_frame = ttk.Frame(ph_container)
        tree_frame.grid(row=1, column=0, sticky="nsew", pady=(5, 0))
        tree_frame.grid_rowconfigure(0, weight=1)
        tree_frame.grid_columnconfigure(0, weight=1)

        self.tree_placeholders = ttk.Treeview(tree_frame, columns=("name", "value"), show='headings')
        self.tree_placeholders.grid(row=0, column=0, sticky="nsew")
        self.tree_placeholders.heading("name", text="Имя")
        self.tree_placeholders.heading("value", text="Значение")
        self.tree_placeholders.column("name", width=200, anchor="w")
        self.tree_placeholders.column("value", width=400, anchor="w")

        scrollbar_ph = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree_placeholders.yview)
        scrollbar_ph.grid(row=0, column=1, sticky="ns")
        self.tree_placeholders.configure(yscrollcommand=scrollbar_ph.set)

        ph_btn_frame = ttk.Frame(tab_set_gen)
        ph_btn_frame.grid(row=7, column=0, sticky="ew", padx=10, pady=5)
        for i, (text, cmd) in enumerate([
            ("Добавить", self.add_placeholder),
            ("Редактировать", self.edit_placeholder),
            ("Удалить", self.delete_placeholder),
            ("Импорт из JSON", self.import_placeholders),
            ("Экспорт в JSON", self.export_placeholders),
        ]):
            ttk.Button(ph_btn_frame, text=text, command=cmd).grid(row=0, column=i, padx=5)

        # Параметры таблицы
        style_label = ttk.Label(tab_set_gen, text="Параметры таблицы:")
        style_label.grid(row=8, column=0, sticky='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab_set_gen)
        style_frm.grid(row=9, column=0, sticky="ew", padx=10, pady=5)

        style_frm.columnconfigure(10, weight=1)

        self.var_header_bold = tk.BooleanVar(value=settings.data['table_style']['header_bold'])
        ttk.Checkbutton(style_frm, text="Жирный заголовок", variable=self.var_header_bold).grid(row=0, column=0, padx=5, pady=2)

        ttk.Label(style_frm, text="Выравнивание:").grid(row=0, column=1, padx=5, pady=2, sticky='w')
        self.combo_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_align.set(settings.data['table_style']['alignment'])
        self.combo_align.grid(row=0, column=2, padx=5, pady=2)

        ttk.Label(style_frm, text="Шрифт:").grid(row=0, column=3, padx=5, pady=2, sticky='w')
        self.entry_font_name = ttk.Entry(style_frm, width=15)
        self.entry_font_name.insert(0, settings.data['table_style']['font_name'])
        self.entry_font_name.grid(row=0, column=4, padx=5, pady=2)

        ttk.Label(style_frm, text="Размер:").grid(row=0, column=5, padx=5, pady=2, sticky='w')
        self.spin_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_font_size.delete(0, 'end')
        self.spin_font_size.insert(0, settings.data['table_style']['font_size'])
        self.spin_font_size.grid(row=0, column=6, padx=5, pady=2)

        ttk.Label(style_frm, text="Стиль рамки:").grid(row=0, column=7, padx=5, pady=2, sticky='w')
        self.combo_border_style = ttk.Combobox(style_frm, values=['нет', 'тонкая', 'толстая'], state='readonly', width=10)
        self.combo_border_style.set(settings.data['table_style'].get('border_style', 'тонкая'))
        self.combo_border_style.grid(row=0, column=8, padx=5, pady=2)

        ttk.Label(style_frm, text="Шрифт заголовка раздела:").grid(row=1, column=0, sticky='w', padx=5, pady=3)
        self.entry_sec_header_font_name = ttk.Entry(style_frm, width=15)
        self.entry_sec_header_font_name.grid(row=1, column=1, padx=5)
        self.entry_sec_header_font_name.insert(0, settings.data['table_style'].get("section_header_font_name", "Calibri"))

        ttk.Label(style_frm, text="Размер шрифта:").grid(row=1, column=2, sticky='w', padx=5, pady=3)
        self.spin_sec_header_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_sec_header_font_size.grid(row=1, column=3, padx=5)
        self.spin_sec_header_font_size.delete(0, "end")
        self.spin_sec_header_font_size.insert(0, settings.data['table_style'].get("section_header_font_size", 12))

        self.var_sec_header_bold = tk.BooleanVar(value=settings.data['table_style'].get("section_header_bold", True))
        ttk.Checkbutton(style_frm, text="Жирный заголовок раздела", variable=self.var_sec_header_bold).grid(row=1, column=4, padx=5)

        ttk.Label(style_frm, text="Выравнивание заголовка:").grid(row=1, column=5, sticky='w', padx=5, pady=3)
        self.combo_sec_header_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_sec_header_align.grid(row=1, column=6, padx=5)
        self.combo_sec_header_align.set(settings.data['table_style'].get("section_header_align", "center"))

        ttk.Label(style_frm, text="Цвет фона заголовка (HEX):").grid(row=1, column=7, sticky='w', padx=5, pady=3)
        self.entry_sec_header_bg_color = ttk.Entry(style_frm, width=12)
        self.entry_sec_header_bg_color.grid(row=1, column=8, padx=5)
        self.entry_sec_header_bg_color.insert(0, settings.data['table_style'].get("section_header_bg_color", "FFFF00"))

        export_label = ttk.Label(tab_set_gen, text="Настройки экспорта:")
        export_label.grid(row=10, column=0, sticky='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab_set_gen)
        export_frm.grid(row=11, column=0, sticky="ew", padx=10, pady=5)
        export_frm.grid_columnconfigure(8, weight=1)

        ttk.Label(export_frm, text="Формат файла:").grid(row=0, column=0, sticky="w", padx=5)
        self.combo_export_format = ttk.Combobox(export_frm, values=['docx', 'pdf'], state='readonly', width=10)
        self.combo_export_format.set(settings.data['export']['format'])
        self.combo_export_format.grid(row=0, column=1, sticky="w", padx=5)

        self.var_compress_images = tk.BooleanVar(value=settings.data['export']['compress_images'])
        ttk.Checkbutton(export_frm, text="Сжимать изображения", variable=self.var_compress_images).grid(row=0, column=2, sticky="w", padx=10)

        ttk.Label(export_frm, text="Пароль для PDF:").grid(row=0, column=3, sticky="w", padx=5)
        self.entry_pdf_password = ttk.Entry(export_frm, width=20, show='*')
        self.entry_pdf_password.insert(0, settings.data['export']['pdf_password'])
        self.entry_pdf_password.grid(row=0, column=4, sticky="w", padx=5)

        ttk.Button(tab_set_gen, text="Сохранить настройки в профиль", command=self.save_profile).grid(row=12, column=0, sticky="e", padx=10, pady=10)

    def on_placeholder_search(self, event=None):
        query = self.var_placeholder_search.get().strip().lower()
        filtered = {}

        for k, v in self.user_placeholders.items():
            if query in k.lower():
                filtered[k] = v

        self.refresh_placeholders_view(filtered)

    def refresh_placeholders_view(self, placeholders_dict=None):
        if placeholders_dict is None:
            placeholders_dict = self.user_placeholders

        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k in sorted(placeholders_dict.keys(), key=lambda s: s.lower()):
            self.tree_placeholders.insert("", "end", values=(k, placeholders_dict[k]))

    def add_placeholder(self):
        name = simpledialog.askstring("Добавить плейсхолдер", "Имя плейсхолдера:", parent=self)
        if not name:
            return
        if name in self.user_placeholders:
            messagebox.showerror("Ошибка", "Такой плейсхолдер уже существует")
            return
        value = simpledialog.askstring("Добавить плейсхолдер", "Значение плейсхолдера:", parent=self)
        if value is None:
            return
        self.user_placeholders[name] = value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Добавлен плейсхолдер: {name} = {value}")

    def edit_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Редактировать", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name, old_value = self.tree_placeholders.item(iid, "values")
        new_value = simpledialog.askstring("Редактировать плейсхолдер", f"Значение плейсхолдера '{name}':", initialvalue=old_value, parent=self)
        if new_value is None:
            return
        self.user_placeholders[name] = new_value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Изменён плейсхолдер: {name} = {new_value}")

    def delete_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Удалить", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name = self.tree_placeholders.item(iid, "values")[0]
        if messagebox.askyesno("Удалить плейсхолдер", f"Удалить плейсхолдер '{name}'?"):
            self.user_placeholders.pop(name, None)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            logger.log(f"Удалён плейсхолдер: {name}")

    def import_placeholders(self):
        path = filedialog.askopenfilename(title="Импорт плейсхолдеров из JSON", filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'r', encoding='utf-8') as f:
                d = json.load(f)
            if not isinstance(d, dict):
                raise ValueError("Файл должен содержать словарь плейсхолдеров")
            self.user_placeholders.update(d)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            messagebox.showinfo("Импорт", f"Импортировано {len(d)} плейсхолдеров")
            logger.log(f"Импортировано плейсхолдеров: {len(d)}")
        except Exception as e:
            messagebox.showerror("Ошибка импорта", str(e))
            logger.log(f"Ошибка импорта плейсхолдеров: {e}")

    def export_placeholders(self):
        path = filedialog.asksaveasfilename(title="Экспорт плейсхолдеров в JSON", defaultextension=".json",
                                            filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'w', encoding='utf-8') as f:
                json.dump(self.user_placeholders, f, ensure_ascii=False, indent=4)
            messagebox.showinfo("Экспорт", f"Плейсхолдеры экспортированы: {path}")
            logger.log(f"Плейсхолдеры экспортированы: {path}")
        except Exception as e:
            messagebox.showerror("Ошибка экспорта", str(e))
            logger.log(f"Ошибка экспорта плейсхолдеров: {e}")

    def export_log(self):
        path = filedialog.asksaveasfilename(title="Сохранить лог", defaultextension=".txt",
                                            filetypes=[("Text files", "*.txt")])
        if not path:
            return
        logger.save_to_file(path)
        messagebox.showinfo("Лог сохранён", f"Лог сохранён в файл:\n{path}")

    def save_gui_to_settings(self):
        d = settings.data
        exp = d.setdefault("export", {})
        exp["format"] = self.combo_export_format.get() if hasattr(self, "combo_export_format") else "docx"
        exp["compress_images"] = self.var_compress_images.get() if hasattr(self, "var_compress_images") else True
        exp["pdf_password"] = self.entry_pdf_password.get() if hasattr(self, "entry_pdf_password") else ""
        exp["template"] = self.combo_templates.get() if hasattr(self, "combo_templates") else None

        ts = d.setdefault("table_style", {})
        ts["header_bold"] = self.var_header_bold.get() if hasattr(self, "var_header_bold") else True
        ts["alignment"] = self.combo_align.get() if hasattr(self, "combo_align") else "center"
        ts["font_name"] = self.entry_font_name.get() if hasattr(self, "entry_font_name") else "Calibri"
        try:
            ts["font_size"] = int(self.spin_font_size.get()) if hasattr(self, "spin_font_size") else 11
        except:
            ts["font_size"] = 11

        ts["border_style"] = self.combo_border_style.get() if hasattr(self, "combo_border_style") else "тонкая"

        ts["section_header_font_name"] = self.entry_sec_header_font_name.get() if hasattr(self, "entry_sec_header_font_name") else "Calibri"
        try:
            ts["section_header_font_size"] = int(self.spin_sec_header_font_size.get()) if hasattr(self, "spin_sec_header_font_size") else 12
        except:
            ts["section_header_font_size"] = 12
        ts["section_header_bold"] = self.var_sec_header_bold.get() if hasattr(self, "var_sec_header_bold") else True
        ts["section_header_align"] = self.combo_sec_header_align.get() if hasattr(self, "combo_sec_header_align") else "center"
        ts["section_header_bg_color"] = self.entry_sec_header_bg_color.get() if hasattr(self, "entry_sec_header_bg_color") else "FFFF00"

        settings.save()

    def load_settings_to_gui(self):
        d = settings.data
        ex = d.get("export", {})
        ts = d.get("table_style", {})

        if hasattr(self, "combo_export_format"):
            self.combo_export_format.set(ex.get("format", "docx"))
        if hasattr(self, "var_compress_images"):
            self.var_compress_images.set(ex.get("compress_images", True))
        if hasattr(self, "entry_pdf_password"):
            self.entry_pdf_password.delete(0, "end")
            self.entry_pdf_password.insert(0, ex.get("pdf_password", ""))

        if hasattr(self, "var_header_bold"):
            self.var_header_bold.set(ts.get("header_bold", True))
        if hasattr(self, "combo_align"):
            self.combo_align.set(ts.get("alignment", "center"))
        if hasattr(self, "entry_font_name"):
            self.entry_font_name.delete(0, "end")
            self.entry_font_name.insert(0, ts.get("font_name", "Calibri"))
        if hasattr(self, "spin_font_size"):
            self.spin_font_size.delete(0, "end")
            self.spin_font_size.insert(0, ts.get("font_size", 11))
        if hasattr(self, "combo_border_style"):
            self.combo_border_style.set(ts.get("border_style", "тонкая"))

        if hasattr(self, "entry_sec_header_font_name"):
            self.entry_sec_header_font_name.delete(0, "end")
            self.entry_sec_header_font_name.insert(0, ts.get("section_header_font_name", "Calibri"))
        if hasattr(self, "spin_sec_header_font_size"):
            self.spin_sec_header_font_size.delete(0, "end")
            self.spin_sec_header_font_size.insert(0, ts.get("section_header_font_size", 12))
        if hasattr(self, "var_sec_header_bold"):
            self.var_sec_header_bold.set(ts.get("section_header_bold", True))
        if hasattr(self, "combo_sec_header_align"):
            self.combo_sec_header_align.set(ts.get("section_header_align", "center"))
        if hasattr(self, "entry_sec_header_bg_color"):
            self.entry_sec_header_bg_color.delete(0, "end")
            self.entry_sec_header_bg_color.insert(0, ts.get("section_header_bg_color", "FFFF00"))

        if hasattr(self, "combo_templates"):
            self.load_templates()
            tmpl = ex.get("template")
            if self.templates:
                if tmpl in self.templates:
                    self.combo_templates.set(tmpl)
                    self.current_template = tmpl
                else:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def load_templates(self):
        self.templates = sorted([f for f in os.listdir(TEMPLATES_DIR) if f.lower().endswith(".docx")])
        if hasattr(self, "combo_templates"):
            self.combo_templates["values"] = self.templates
            if self.templates:
                if self.current_template not in self.templates:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def generate_document(self, path=None, show_messages=True):
        if self.basket_df.empty:
            if show_messages:
                messagebox.showwarning("Генерация", "Корзина пуста")
            return
        if not self.current_template:
            if show_messages:
                messagebox.showwarning("Генерация", "Шаблон не выбран")
            return

        self.save_gui_to_settings()

        save_dir = None
        if path is None:
            save_dir = filedialog.askdirectory(title="Выберите папку для сохранения документов")
            if not save_dir:
                return
        else:
            save_dir = os.path.dirname(path)

        try:
            template_path = os.path.join(TEMPLATES_DIR, self.current_template)
            phs_global = self.user_placeholders.copy()
            unique_arts = list(dict.fromkeys(self.basket_df["Артикул"]))

            for art in unique_arts:
                doc = Document(template_path)

                if art in self.grouped_data:
                    info = self.grouped_data[art]
                    art_info = {
                        "Объект": info["object_full"],
                        "Тип товара": info["product_type"],
                        "Артикул": art,
                    }
                    art_sections = info["sections"]
                else:
                    art_info = {"Объект": "", "Тип товара": "", "Артикул": art}
                    art_sections = []

                phs = dict(phs_global)
                phs.update(art_info)

                for section in art_sections:
                    for param in section["params"]:
                        pname = param["parameter"]
                        pvalue = param["value"]
                        if pname in phs and phs[pname] == "{{" + pname + "}}":
                            phs[pname] = pvalue

                for k, v in phs.items():
                    docx_replace_placeholder(doc, k, v)

                placeholder_paragraph = None
                for p in doc.paragraphs:
                    if "{{Таблица}}" in p.text:
                        placeholder_paragraph = p
                        break

                if placeholder_paragraph is None:
                    logger.log(f"Плейсхолдер {{Таблица}} не найден в шаблоне для артикула {art}")
                    if show_messages:
                        messagebox.showwarning("Генерация",
                                               f"Шаблон не содержит плейсхолдера {{Таблица}} для артикула {art}")
                    filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                    file_path = os.path.join(save_dir, filename)
                    doc.save(file_path)
                    continue

                style = settings.data.get("table_style", {})
                border_style = style.get("border_style", "тонкая")

                previous_element = placeholder_paragraph._element

                sh_font_name = style.get("section_header_font_name", "Calibri")
                sh_font_size = style.get("section_header_font_size", 12)
                sh_bold = style.get("section_header_bold", True)
                sh_align = style.get("section_header_align", "center")
                sh_bg_color = style.get("section_header_bg_color", "FFFF00")

                for section in art_sections:
                    tbl = doc.add_table(rows=1, cols=2)
                    set_table_border(tbl, border_style)

                    row = tbl.rows[0]
                    cell = row.cells[0]
                    cell.merge(row.cells[1])
                    cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER

                    paragraph = cell.paragraphs[0]
                    paragraph.alignment = {
                        "left": WD_ALIGN_PARAGRAPH.LEFT,
                        "center": WD_ALIGN_PARAGRAPH.CENTER,
                        "right": WD_ALIGN_PARAGRAPH.RIGHT,
                    }.get(sh_align, WD_ALIGN_PARAGRAPH.CENTER)
                    for r in paragraph.runs:
                        r.text = ''
                    run = paragraph.add_run(section.get("header", ""))
                    run.font.name = sh_font_name
                    run.font.size = Pt(sh_font_size)
                    run.bold = sh_bold
                    set_cell_background(cell, sh_bg_color)

                    for param in section["params"]:
                        row_cells = tbl.add_row().cells
                        row_cells[0].text = str(param["parameter"])
                        row_cells[1].text = str(param["value"])
                        for i in (0, 1):
                            p_cell = row_cells[i].paragraphs[0]
                            p_cell.style = None
                            for r in p_cell.runs:
                                r.font.name = style.get("font_name", "Calibri")
                                r.font.size = Pt(style.get("font_size", 11))
                                r.bold = False
                            p_cell.alignment = {
                                "left": WD_ALIGN_PARAGRAPH.LEFT,
                                "right": WD_ALIGN_PARAGRAPH.RIGHT,
                                "center": WD_ALIGN_PARAGRAPH.CENTER,
                            }.get(style.get("alignment", "center"), WD_ALIGN_PARAGRAPH.CENTER)

                    previous_element.addnext(tbl._element)
                    previous_element = tbl._element

                p = placeholder_paragraph._element
                p.getparent().remove(p)

                filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                file_path = os.path.join(save_dir, filename)
                doc.save(file_path)
                logger.log(f"Создан файл для артикула {art}: {file_path}")

                if settings.data.get("export", {}).get("format") == "pdf":
                    pdf_path = os.path.splitext(file_path)[0] + ".pdf"
                    if WIN32COM_AVAILABLE:
                        convert_docx_to_pdf(file_path, pdf_path)
                        logger.log(f"PDF создан для артикула {art}: {pdf_path}")
                    else:
                        logger.log("Для создания PDF необходим MS Word и pywin32")

            if show_messages:
                messagebox.showinfo("Успех", f"Обработано товаров: {len(unique_arts)}\nФайлы сохранены в:\n{save_dir}")

        except Exception as e:
            traceback.print_exc()
            logger.log(f"Ошибка генерации документа: {e}")
            if show_messages:
                messagebox.showerror("Ошибка генерации", str(e))

    def load_excel_dialog(self):
        path = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel Files", "*.xlsx *.xls")])
        if not path:
            return
        try:
            self.grouped_data = load_and_transform_excel_grouped(path)

            param_dict = {}
            for info in self.grouped_data.values():
                for section in info['sections']:
                    for param in section['params']:
                        pname, pval = param['parameter'], param['value']
                        if pname not in param_dict:
                            param_dict[pname] = set()
                        param_dict[pname].add(pval)
            self.all_params = sorted(param_dict.keys())
            self.param_values = dict((k, sorted(v)) for k, v in param_dict.items())

            for row in self.param_filter_rows:
                row['param']['values'] = self.all_params
                p = row['param'].get()
                if p and p in self.param_values:
                    row['value']['values'] = [''] + self.param_values[p]
                else:
                    row['value']['values'] = []
                row['value'].set('')

            rows = []
            for art, info in self.grouped_data.items():
                obj = info["object_full"]
                ptype = info["product_type"]
                rows.append({"Объект": obj, "Тип товара": ptype, "Артикул": art})
            self.filtered_df = pd.DataFrame(rows)
            self.filtered_df_backup = self.filtered_df.copy()
            self.apply_all_filters()

            self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])
            self.update_basket_table()

            self.create_placeholders_from_loaded_products()

            messagebox.showinfo("Успех", f"Данные загружены: {len(self.filtered_df_backup)} записей")
            logger.log(f"Файл загружен: {path}, записей: {len(self.filtered_df_backup)}")
        except Exception as e:
            traceback.print_exc()
            messagebox.showerror("Ошибка загрузки", str(e))
            logger.log(f"Ошибка загрузки: {e}")

    def update_data_table(self):
        self.tree_data.delete(*self.tree_data.get_children())
        if self.filtered_df.empty:
            self.tree_data["columns"] = []
            return
        self.tree_data["columns"] = list(self.filtered_df.columns)
        for col in self.filtered_df.columns:
            self.tree_data.heading(col, text=col)
            self.tree_data.column(col, width=160, anchor="w")
        for _, row in self.filtered_df.iterrows():
            self.tree_data.insert("", "end", values=list(row))

    def quick_search(self, event=None):
        self.apply_all_filters()

    def show_item_params(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            return
        iid = sel[0]
        item = self.tree_data.item(iid)
        art = item["values"][2]
        if art in self.grouped_data:
            if self.grouped_data[art]["sections"]:
                params = self.grouped_data[art]["sections"][0]["params"]
            else:
                params = []
            self.tree_params.delete(*self.tree_params.get_children())
            for param in params:
                self.tree_params.insert("", "end", values=(param["parameter"], param["value"]))

    def add_selected_to_basket(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            messagebox.showinfo("Добавление", "Выделите записи для добавления в корзину")
            return
        rows = [self.tree_data.item(iid)["values"] for iid in sel]
        df_add = pd.DataFrame(rows, columns=["Объект", "Тип товара", "Артикул"])
        self.basket_df = pd.concat([self.basket_df, df_add], ignore_index=True)
        self.basket_df.drop_duplicates(inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        logger.log(f"В корзину добавлено: {len(rows)} записей")

    def update_basket_table(self):
        self.tree_basket.delete(*self.tree_basket.get_children())
        if self.basket_df.empty:
            self.tree_basket["columns"] = []
            return
        self.tree_basket["columns"] = list(self.basket_df.columns)
        for col in self.basket_df.columns:
            self.tree_basket.heading(col, text=col)
            self.tree_basket.column(col, width=160, anchor="w")
        for _, row in self.basket_df.iterrows():
            self.tree_basket.insert("", "end", values=list(row))

    def remove_selected_from_basket(self):
        sel = self.tree_basket.selection()
        if not sel:
            messagebox.showinfo("Удаление", "Выделите записи для удаления из корзины")
            return
        idxs = [self.tree_basket.index(iid) for iid in sel]
        self.basket_df.drop(self.basket_df.index[idxs], inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        logger.log(f"Удалено из корзины: {len(idxs)} записей")

    def clear_basket(self):
        if messagebox.askyesno("Очистка корзины", "Очистить корзину?"):
            self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])
            self.update_basket_table()
            logger.log("Корзина очищена")

    def context_menu_data(self, event):
        iid = self.tree_data.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Добавить в корзину", command=self.add_selected_to_basket)
        menu.post(event.x_root, event.y_root)

    def context_menu_basket(self, event):
        iid = self.tree_basket.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Удалить из корзины", command=self.remove_selected_from_basket)
        menu.post(event.x_root, event.y_root)

    def preview_template(self):
        if not self.current_template:
            messagebox.showwarning("Предпросмотр", "Шаблон не выбран")
            return
        path = os.path.join(TEMPLATES_DIR, self.current_template)
        try:
            if os.name == 'nt':
                os.startfile(path)
            else:
                try:
                    subprocess.Popen(["open", path])
                except:
                    subprocess.Popen(["xdg-open", path])
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка предпросмотра шаблона:\n{e}")

    def save_profile(self):
        try:
            self.save_gui_to_settings()
            if not self.selected_profile:
                prof_name = simpledialog.askstring("Сохранить профиль", "Имя профиля (без расширения):", parent=self)
                if not prof_name:
                    messagebox.showinfo("Отмена", "Сохранение отменено")
                    return
                fname = prof_name.strip() + ".json"
            else:
                fname = self.selected_profile
            if settings.save_profile(fname):
                self.selected_profile = fname
                self.load_profiles()
                self.combo_profiles.set(fname)
                messagebox.showinfo("Успех", f"Профиль {fname} сохранён")
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка сохранения профиля:\n{e}")

    def load_profile(self):
        prof = self.combo_profiles.get()
        if not prof:
            return
        if settings.load_profile(prof):
            self.selected_profile = prof
            self.load_settings_to_gui()
            messagebox.showinfo("Успех", f"Профиль {prof} загружен")

    def load_profiles(self):
        if not os.path.exists(PROFILES_DIR):
            os.makedirs(PROFILES_DIR)
        files = sorted([f for f in os.listdir(PROFILES_DIR) if f.endswith('.json')])
        self.combo_profiles['values'] = files
        if files and not self.selected_profile:
            self.combo_profiles.set(files[0])
            self.selected_profile = files[0]

    def on_profile_change(self, event=None):
        selected = self.combo_profiles.get()
        if selected:
            self.selected_profile = selected
            if settings.load_profile(selected):
                self.load_settings_to_gui()
                messagebox.showinfo("Профиль", f"Профиль '{selected}' загружен")
            else:
                messagebox.showerror("Ошибка", f"Ошибка загрузки профиля: {selected}")

    def on_template_change(self, event=None):
        selected = self.combo_templates.get()
        if selected:
            self.current_template = selected
            logger.log(f"Выбран шаблон: {selected}")

    def load_placeholders_to_tree(self):
        self.user_placeholders = placeholders.placeholders.copy()
        self.refresh_placeholders_view()

    def add_param_filter_row(self):
        row_frame = ttk.Frame(self.param_filters_container)
        row_frame.pack(fill="x", pady=2)

        param_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        param_combo['values'] = self.all_params
        param_combo.pack(side='left', padx=2)
        param_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, rf=row_frame: self.on_param_combo_change(c, rf))

        value_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        value_combo.pack(side='left', padx=2)
        value_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, v=value_combo: self.on_param_value_change(c, v))

        del_btn = ttk.Button(row_frame, text="✖", width=3,
                             command=lambda rf=row_frame: self.remove_param_filter_row(rf))
        del_btn.pack(side='left', padx=2)

        self.param_filter_rows.append({'frame': row_frame, 'param': param_combo, 'value': value_combo, 'del_btn': del_btn})

    def remove_param_filter_row(self, row_frame):
        self.param_filter_rows = [r for r in self.param_filter_rows if r['frame'] != row_frame]
        row_frame.destroy()
        self.apply_all_filters()

    def on_param_combo_change(self, param_combo, row_frame):
        param = param_combo.get()
        value_combo = None
        for row in self.param_filter_rows:
            if row['frame'] == row_frame:
                value_combo = row['value']
                break
        if value_combo:
            values = []
            if param and param in self.param_values:
                values = sorted(self.param_values[param])
            value_combo['values'] = [''] + values
            value_combo.set('')
        self.apply_all_filters()

    def on_param_value_change(self, param_combo, value_combo):
        self.apply_all_filters()

    def collect_param_filters(self):
        """
        Считывает все выбранные фильтры параметров из интерфейса,
        возвращает dict параметр: множество значений для фильтра; фильтры без значения игнорируются
        """
        res = {}
        for row in self.param_filter_rows:
            p = row['param'].get()
            v = row['value'].get()
            if p and v:
                res.setdefault(p, set()).add(v)
        return res

    def apply_all_filters(self):
        """
        Применяет все фильтры к исходным данным:
        - быстрый поиск по артикулу
        - фильтрация по параметрам (каждый параметр — набор возможных значений)
        """
        if self.filtered_df_backup.empty:
            return

        df = self.filtered_df_backup.copy()

        # Фильтр по артикулу (ввод в строку поиска)
        q = self.var_quick_search.get().lower().strip()
        if q:
            df = df[df['Артикул'].str.lower().str.contains(q, na=False)]

        param_filters = self.collect_param_filters()
        if param_filters:
            filtered_arts = []
            for art in df['Артикул']:
                info = self.grouped_data.get(art)
                if not info:
                    continue
                all_match = True
                for p, vals in param_filters.items():
                    match = False
                    for section in info['sections']:
                        for param_dict in section['params']:
                            if param_dict['parameter'] == p and param_dict['value'] in vals:
                                match = True
                                break
                        if match:
                            break
                    if not match:
                        all_match = False
                        break
                if all_match:
                    filtered_arts.append(art)
            df = df[df['Артикул'].isin(filtered_arts)]

        self.filtered_df = df
        self.update_data_table()


def convert_docx_to_pdf(docx_path, pdf_path):
    word = win32com.client.Dispatch("Word.Application")
    word.Visible = False
    doc = word.Documents.Open(docx_path)
    doc.SaveAs(pdf_path, FileFormat=17)
    doc.Close()
    word.Quit()


def main():
    app = App()
    app.mainloop()


if __name__ == '__main__':
    main()

или

import os
import json
import traceback
from datetime import datetime
import subprocess
import threading

import pandas as pd
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.oxml.ns import qn, nsdecls
from docx.oxml import OxmlElement, parse_xml

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog

try:
    import win32com.client

    WIN32COM_AVAILABLE = True
except ImportError:
    WIN32COM_AVAILABLE = False

WORKDIR = os.getcwd()
TEMPLATES_DIR = os.path.join(WORKDIR, "templates")
CONFIG_DIR = os.path.join(WORKDIR, "config")
PROFILES_DIR = os.path.join(CONFIG_DIR, "profiles")

for d in (TEMPLATES_DIR, CONFIG_DIR, PROFILES_DIR):
    os.makedirs(d, exist_ok=True)

SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json")
PLACEHOLDERS_FILE = os.path.join(CONFIG_DIR, "placeholders.json")
MANDATORY_COLS = ['Объект', 'Свойство', 'Значение']


class Logger:
    def __init__(self, ui_log_callback=None):
        self.history = []
        self.ui_log_callback = ui_log_callback

    def log(self, msg):
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        record = f"[{ts}] {msg}"
        self.history.append(record)
        print(record)
        if self.ui_log_callback:
            self.ui_log_callback(record)

    def get_text(self):
        return "\n".join(self.history)

    def save_to_file(self, path):
        try:
            with open(path, 'w', encoding='utf-8') as f:
                f.write(self.get_text())
            self.log(f"Лог сохранён: {path}")
        except Exception as e:
            self.log(f"Ошибка сохранения лога: {e}")


class Settings:
    def __init__(self, logger=None):
        self.logger = logger
        self.data = {
            "table_style": {
                "header_bold": True,
                "alignment": "center",
                "font_name": "Calibri",
                "font_size": 11,
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            },
            "export": {
                "format": "docx",
                "pdf_password": "",
                "compress_images": True,
                "template": None
            }
        }
        self.load()

    def _apply_defaults(self):
        exp = self.data.setdefault('export', {})
        exp.setdefault('format', 'docx')
        exp.setdefault('pdf_password', '')
        exp.setdefault('compress_images', True)

        ts = self.data.setdefault('table_style', {})
        defaults = {
            "border_style": "тонкая",
            "section_header_font_name": "Calibri",
            "section_header_font_size": 12,
            "section_header_bold": True,
            "section_header_align": "center",
            "section_header_bg_color": "FFFF00"
        }
        for k, v in defaults.items():
            ts.setdefault(k, v)

    def load(self):
        if os.path.exists(SETTINGS_FILE):
            try:
                with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                    d = json.load(f)
                self.data.update(d)
                self._apply_defaults()
                if self.logger:
                    self.logger.log("Настройки загружены")
            except Exception as e:
                if self.logger:
                    self.logger.log(f"Ошибка загрузки настроек: {e}")

    def save(self):
        try:
            with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            if self.logger:
                self.logger.log("Настройки сохранены")
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка сохранения настроек: {e}")

    def save_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            if self.logger:
                self.logger.log(f"Профиль сохранён: {filename}")
            return True
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка сохранения профиля: {e}")
            return False

    def load_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'r', encoding='utf-8') as f:
                d = json.load(f)
            self.data.update(d)
            self._apply_defaults()
            if self.logger:
                self.logger.log(f"Профиль загружен: {filename}")
            return True
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка загрузки профиля: {e}")
            return False


class Placeholders:
    def __init__(self, logger=None):
        self.placeholders = {}
        self.logger = logger
        self.load()

    def load(self):
        if os.path.isfile(PLACEHOLDERS_FILE):
            try:
                with open(PLACEHOLDERS_FILE, 'r', encoding='utf-8') as f:
                    self.placeholders = json.load(f)
                if self.logger:
                    self.logger.log("Плейсхолдеры загружены")
            except Exception as e:
                if self.logger:
                    self.logger.log(f"Ошибка загрузки плейсхолдеров: {e}")

    def save(self):
        try:
            with open(PLACEHOLDERS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.placeholders, f, ensure_ascii=False, indent=4)
            if self.logger:
                self.logger.log("Плейсхолдеры сохранены")
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка сохранения плейсхолдеров: {e}")


def split_by_idis(obj):
    if not isinstance(obj, str) or not obj.strip():
        return "", ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        return obj[:idx].strip(), obj[idx + 4:].strip()
    return obj, ""


def extract_article_full(obj):
    _, article = split_by_idis(obj)
    if article:
        return article
    words = obj.strip().split()
    if len(words) <= 2:
        return obj.strip()
    return " ".join(words[-3:]).strip()


def extract_product_type(obj):
    product_type, _ = split_by_idis(obj)
    if product_type:
        return product_type
    words = obj.strip().split()
    if len(words) <= 3:
        return " ".join(words)
    return " ".join(words[:3])


def extract_param(prop):
    if not isinstance(prop, str):
        return ""
    return prop.split("_")[-1].strip()


def load_and_transform_excel_grouped(path):
    df = pd.read_excel(path, engine="openpyxl")
    missing = [c for c in MANDATORY_COLS if c not in df.columns]
    if missing:
        raise ValueError(f"Отсутствуют колонки: {missing}")

    df["Тип товара"] = df["Объект"].apply(extract_product_type)
    df["Артикул"] = df["Объект"].apply(extract_article_full)
    df["Параметр"] = df["Свойство"].apply(extract_param)
    df["Значение"] = df["Значение"].astype(str)
    df = df[(df["Артикул"] != "") & (df["Параметр"] != "")].reset_index(drop=True)

    grouped = {}
    for art, gdf in df.groupby("Артикул"):
        obj_full = gdf["Объект"].iloc[0]
        prod_type = gdf["Тип товара"].iloc[0]

        sections = []
        current_header = None
        current_params = []

        for _, row in gdf.iterrows():
            if row["Значение"].strip().lower() == "<раздел>":
                if current_header is not None or current_params:
                    sections.append({"header": current_header, "params": current_params})
                current_header = row["Параметр"]
                current_params = []
            else:
                current_params.append({"parameter": row["Параметр"], "value": row["Значение"]})

        if current_header is not None or current_params:
            sections.append({"header": current_header, "params": current_params})

        if not sections:
            sections = [
                {
                    "header": None,
                    "params": [{"parameter": r["Параметр"], "value": r["Значение"]} for _, r in gdf.iterrows()],
                }
            ]

        grouped[art] = {"object_full": obj_full, "product_type": prod_type, "sections": sections}
    return grouped


def inline_replace_in_paragraph(paragraph, placeholder, replacement):
    runs = paragraph.runs
    full_text = "".join(run.text for run in runs)
    if placeholder not in full_text:
        return
    new_text = full_text.replace(placeholder, replacement)
    for run in runs:
        run.text = ""
    if runs:
        runs[0].text = new_text


def docx_replace_placeholder(doc, key, value):
    ph = "{{" + key + "}}"
    for p in doc.paragraphs:
        if ph in p.text:
            inline_replace_in_paragraph(p, ph, str(value))
    for tbl in doc.tables:
        for row in tbl.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    if ph in p.text:
                        inline_replace_in_paragraph(p, ph, str(value))
    replace_placeholder_in_header_footer(doc, key, value)


def replace_placeholder_in_header_footer(doc, key, value):
    ph = "{{" + key + "}}"
    for section in doc.sections:
        header = section.header
        for p in header.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in header.tables:
            for row in tbl.rows:
                for cell in row.cells:
                    for p in cell.paragraphs:
                        if ph in p.text:
                            inline_replace_in_paragraph(p, ph, str(value))
        footer = section.footer
        for p in footer.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in footer.tables:
            for row in tbl.rows:
                for cell in row.cells:
                    for p in cell.paragraphs:
                        if ph in p.text:
                            inline_replace_in_paragraph(p, ph, str(value))


def set_table_border(table, border_style):
    borders_templates = {
        'нет': """
            <w:tblBorders %s>
                <w:top w:val="nil"/>
                <w:left w:val="nil"/>
                <w:bottom w:val="nil"/>
                <w:right w:val="nil"/>
                <w:insideH w:val="nil"/>
                <w:insideV w:val="nil"/>
            </w:tblBorders>
        """,
        'тонкая': """
            <w:tblBorders %s>
                <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
                <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            </w:tblBorders>
        """,
        'толстая': """
            <w:tblBorders %s>
                <w:top w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:left w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:bottom w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:right w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:insideH w:val="single" w:sz="12" w:space="0" w:color="000000"/>
                <w:insideV w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            </w:tblBorders>
        """,
    }

    border_xml = borders_templates.get(border_style.lower(), borders_templates['тонкая']) % nsdecls('w')
    tblPr = table._element.tblPr

    for child in tblPr.findall("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tblBorders"):
        tblPr.remove(child)

    borders = parse_xml(border_xml)
    tblPr.append(borders)


def set_cell_background(cell, hex_color):
    tc = cell._tc
    tcPr = tc.get_or_add_tcPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:fill'), hex_color)
    tcPr.append(shd)


def convert_docx_to_pdf(docx_path, pdf_path):
    if not WIN32COM_AVAILABLE:
        raise RuntimeError("Для создания PDF необходим MS Word и pywin32")
    word = win32com.client.Dispatch("Word.Application")
    word.Visible = False
    doc = word.Documents.Open(docx_path)
    doc.SaveAs(pdf_path, FileFormat=17)
    doc.Close()
    word.Quit()


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Автоматизация техдокументации")
        self.geometry("1200x720")
        self.minsize(1000, 600)

        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)

        self.logger = Logger(ui_log_callback=self.append_log_to_text_widget)
        self.settings = Settings(logger=self.logger)
        self.placeholders = Placeholders(logger=self.logger)

        self.grouped_data = {}
        self.filtered_df = pd.DataFrame()
        self.filtered_df_backup = pd.DataFrame()
        self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])

        self.all_params = []
        self.param_values = {}

        self.user_placeholders = self.placeholders.placeholders.copy()
        self.templates = []
        self.current_template = None
        self.selected_profile = None

        self.param_filter_rows = []

        self.var_quick_search = tk.StringVar()
        self.var_placeholder_search = tk.StringVar()
        self.var_header_bold = tk.BooleanVar()
        self.var_compress_images = tk.BooleanVar()
        self.var_sec_header_bold = tk.BooleanVar()

        self.create_widgets()
        self.create_menu()
        self.bind_shortcuts()

        self.load_templates()
        self.load_profiles()
        self.load_placeholders_to_tree()
        self.load_settings_to_gui()
        self.create_placeholders_from_loaded_products()

        self.logger.log("Приложение запущено")

    def append_log_to_text_widget(self, record):
        # Реализация вывода лога в UI (можно добавить виджет Text для логов)
        pass

    def load_placeholders_to_tree(self):
        self.user_placeholders = self.placeholders.placeholders.copy()
        self.refresh_placeholders_view()

    def refresh_placeholders_view(self, placeholders_dict=None):
        if placeholders_dict is None:
            placeholders_dict = self.user_placeholders

        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k in sorted(placeholders_dict.keys(), key=lambda s: s.lower()):
            self.tree_placeholders.insert("", "end", values=(k, placeholders_dict[k]))

    def on_placeholder_search(self, event=None):
        query = self.var_placeholder_search.get().strip().lower()
        filtered = {}
        for k, v in self.user_placeholders.items():
            if query in k.lower():
                filtered[k] = v
        self.refresh_placeholders_view(filtered)

    def add_placeholder(self):
        name = simpledialog.askstring("Добавить плейсхолдер", "Имя плейсхолдера:", parent=self)
        if not name:
            return
        if name in self.user_placeholders:
            messagebox.showerror("Ошибка", "Такой плейсхолдер уже существует")
            return
        value = simpledialog.askstring("Добавить плейсхолдер", "Значение плейсхолдера:", parent=self)
        if value is None:
            return
        self.user_placeholders[name] = value
        self.placeholders.placeholders = self.user_placeholders.copy()
        self.placeholders.save()
        self.refresh_placeholders_view()
        self.logger.log(f"Добавлен плейсхолдер: {name} = {value}")

    def edit_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Редактировать", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name, old_value = self.tree_placeholders.item(iid, "values")
        new_value = simpledialog.askstring("Редактировать плейсхолдер", f"Значение плейсхолдера '{name}':", initialvalue=old_value, parent=self)
        if new_value is None:
            return
        self.user_placeholders[name] = new_value
        self.placeholders.placeholders = self.user_placeholders.copy()
        self.placeholders.save()
        self.refresh_placeholders_view()
        self.logger.log(f"Изменён плейсхолдер: {name} = {new_value}")

    def delete_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Удалить", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name = self.tree_placeholders.item(iid, "values")[0]
        if messagebox.askyesno("Удалить плейсхолдер", f"Удалить плейсхолдер '{name}'?"):
            self.user_placeholders.pop(name, None)
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            self.logger.log(f"Удалён плейсхолдер: {name}")

    def import_placeholders(self):
        path = filedialog.askopenfilename(title="Импорт плейсхолдеров из JSON", filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'r', encoding='utf-8') as f:
                d = json.load(f)
            if not isinstance(d, dict):
                raise ValueError("Файл должен содержать словарь плейсхолдеров")
            self.user_placeholders.update(d)
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            messagebox.showinfo("Импорт", f"Импортировано {len(d)} плейсхолдеров")
            self.logger.log(f"Импортировано плейсхолдеров: {len(d)}")
        except Exception as e:
            messagebox.showerror("Ошибка импорта", str(e))
            self.logger.log(f"Ошибка импорта плейсхолдеров: {e}")

    def export_placeholders(self):
        path = filedialog.asksaveasfilename(title="Экспорт плейсхолдеров в JSON", defaultextension=".json",
                                            filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'w', encoding='utf-8') as f:
                json.dump(self.user_placeholders, f, ensure_ascii=False, indent=4)
            messagebox.showinfo("Экспорт", f"Плейсхолдеры экспортированы: {path}")
            self.logger.log(f"Плейсхолдеры экспортированы: {path}")
        except Exception as e:
            messagebox.showerror("Ошибка экспорта", str(e))
            self.logger.log(f"Ошибка экспорта плейсхолдеров: {e}")

    def save_profile(self):
        try:
            self.save_gui_to_settings()
            if not self.selected_profile:
                prof_name = simpledialog.askstring("Сохранить профиль", "Имя профиля (без расширения):", parent=self)
                if not prof_name:
                    messagebox.showinfo("Отмена", "Сохранение отменено")
                    return
                fname = prof_name.strip() + ".json"
            else:
                fname = self.selected_profile
            if self.settings.save_profile(fname):
                self.selected_profile = fname
                self.load_profiles()
                self.combo_profiles.set(fname)
                messagebox.showinfo("Успех", f"Профиль {fname} сохранён")
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка сохранения профиля:\n{e}")

    def load_profile(self):
        prof = self.combo_profiles.get()
        if not prof:
            return
        if self.settings.load_profile(prof):
            self.selected_profile = prof
            self.load_settings_to_gui()
            messagebox.showinfo("Успех", f"Профиль {prof} загружен")

    def load_profiles(self):
        if not os.path.exists(PROFILES_DIR):
            os.makedirs(PROFILES_DIR)
        files = sorted([f for f in os.listdir(PROFILES_DIR) if f.endswith('.json')])
        self.combo_profiles['values'] = files
        if files and not self.selected_profile:
            self.combo_profiles.set(files[0])
            self.selected_profile = files[0]

    def on_profile_change(self, event=None):
        selected = self.combo_profiles.get()
        if selected:
            self.selected_profile = selected
            if self.settings.load_profile(selected):
                self.load_settings_to_gui()
                messagebox.showinfo("Профиль", f"Профиль '{selected}' загружен")
            else:
                messagebox.showerror("Ошибка", f"Ошибка загрузки профиля: {selected}")

    def load_templates(self):
        self.templates = sorted([f for f in os.listdir(TEMPLATES_DIR) if f.lower().endswith(".docx")])
        self.combo_templates["values"] = self.templates
        if self.templates:
            if not self.current_template or self.current_template not in self.templates:
                self.current_template = self.templates[0]
            self.combo_templates.set(self.current_template)

    def on_template_change(self, event=None):
        selected = self.combo_templates.get()
        if selected:
            self.current_template = selected
            self.logger.log(f"Выбран шаблон: {selected}")

    def preview_template(self):
        if not self.current_template:
            messagebox.showwarning("Предпросмотр", "Шаблон не выбран")
            return
        path = os.path.join(TEMPLATES_DIR, self.current_template)
        try:
            if os.name == 'nt':
                os.startfile(path)
            else:
                try:
                    subprocess.Popen(["open", path])
                except:
                    subprocess.Popen(["xdg-open", path])
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка предпросмотра шаблона:\n{e}")

    def export_log(self):
        path = filedialog.asksaveasfilename(title="Сохранить лог", defaultextension=".txt",
                                            filetypes=[("Text files", "*.txt")])
        if not path:
            return
        self.logger.save_to_file(path)
        messagebox.showinfo("Лог сохранён", f"Лог сохранён в файл:\n{path}")

    def add_param_filter_row(self):
        row_frame = ttk.Frame(self.param_filters_container)
        row_frame.pack(fill="x", pady=2)

        param_combo = ttk.Combobox(row_frame, state="readonly", width=25, postcommand=lambda: param_combo.configure(values=self.all_params))
        param_combo.pack(side='left', padx=2)
        param_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, rf=row_frame: self.on_param_combo_change(c, rf))

        value_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        value_combo.pack(side='left', padx=2)
        value_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, v=value_combo: self.on_param_value_change(c, v))

        del_btn = ttk.Button(row_frame, text="✖", width=3,
                             command=lambda rf=row_frame: self.remove_param_filter_row(rf))
        del_btn.pack(side='left', padx=2)

        self.param_filter_rows.append({'frame': row_frame, 'param': param_combo, 'value': value_combo, 'del_btn': del_btn})

    def remove_param_filter_row(self, row_frame):
        self.param_filter_rows = [r for r in self.param_filter_rows if r['frame'] != row_frame]
        row_frame.destroy()
        self.apply_all_filters()

    def on_param_combo_change(self, param_combo, row_frame):
        param = param_combo.get()
        value_combo = None
        for row in self.param_filter_rows:
            if row['frame'] == row_frame:
                value_combo = row['value']
                break
        if value_combo:
            values = []
            if param and param in self.param_values:
                values = sorted(self.param_values[param])
            value_combo['values'] = [''] + values
            value_combo.set('')
        self.apply_all_filters()

    def on_param_value_change(self, param_combo, value_combo):
        self.apply_all_filters()

    def collect_param_filters(self):
        res = {}
        for row in self.param_filter_rows:
            p = row['param'].get()
            v = row['value'].get()
            if p and v:
                res.setdefault(p, set()).add(v)
        return res

    def quick_search(self, event=None):
        self.apply_all_filters()

    def apply_all_filters(self):
        if self.filtered_df_backup.empty:
            return

        df = self.filtered_df_backup.copy()

        q = self.var_quick_search.get().lower().strip()
        if q:
            df = df[df['Артикул'].str.lower().str.contains(q, na=False)]

        param_filters = self.collect_param_filters()
        if param_filters:
            filtered_arts = []
            for art in df['Артикул']:
                info = self.grouped_data.get(art)
                if not info:
                    continue
                all_match = True
                for p, vals in param_filters.items():
                    match = any(
                        param_dict['parameter'] == p and param_dict['value'] in vals
                        for section in info['sections']
                        for param_dict in section['params']
                    )
                    if not match:
                        all_match = False
                        break
                if all_match:
                    filtered_arts.append(art)
            df = df[df['Артикул'].isin(filtered_arts)]

        self.filtered_df = df
        self.update_data_table()

    def update_data_table(self):
        self.tree_data.delete(*self.tree_data.get_children())
        if self.filtered_df.empty:
            self.tree_data["columns"] = []
            return
        self.tree_data["columns"] = list(self.filtered_df.columns)
        for col in self.filtered_df.columns:
            self.tree_data.heading(col, text=col)
            self.tree_data.column(col, width=160, anchor="w")
        for _, row in self.filtered_df.iterrows():
            self.tree_data.insert("", "end", values=list(row))

    def show_item_params(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            return
        iid = sel[0]
        item = self.tree_data.item(iid)
        if not item or not item.get("values"):
            return
        art = item["values"][2]
        if art in self.grouped_data:
            if self.grouped_data[art]["sections"]:
                params = self.grouped_data[art]["sections"][0]["params"]
            else:
                params = []
            self.tree_params.delete(*self.tree_params.get_children())
            for param in params:
                self.tree_params.insert("", "end", values=(param["parameter"], param["value"]))

    def add_selected_to_basket(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            messagebox.showinfo("Добавление", "Выделите записи для добавления в корзину")
            return
        rows = [self.tree_data.item(iid)["values"] for iid in sel]
        df_add = pd.DataFrame(rows, columns=["Объект", "Тип товара", "Артикул"])
        self.basket_df = pd.concat([self.basket_df, df_add], ignore_index=True)
        self.basket_df.drop_duplicates(inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        self.logger.log(f"В корзину добавлено: {len(rows)} записей")

    def update_basket_table(self):
        self.tree_basket.delete(*self.tree_basket.get_children())
        if self.basket_df.empty:
            self.tree_basket["columns"] = []
            return
        self.tree_basket["columns"] = list(self.basket_df.columns)
        for col in self.basket_df.columns:
            self.tree_basket.heading(col, text=col)
            self.tree_basket.column(col, width=160, anchor="w")
        for _, row in self.basket_df.iterrows():
            self.tree_basket.insert("", "end", values=list(row))

    def remove_selected_from_basket(self):
        sel = self.tree_basket.selection()
        if not sel:
            messagebox.showinfo("Удаление", "Выделите записи для удаления из корзины")
            return
        idxs = [self.tree_basket.index(iid) for iid in sel]
        self.basket_df.drop(self.basket_df.index[idxs], inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        self.logger.log(f"Удалено из корзины: {len(idxs)} записей")

    def clear_basket(self):
        if messagebox.askyesno("Очистка корзины", "Очистить корзину?"):
            self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])
            self.update_basket_table()
            self.logger.log("Корзина очищена")

    def save_gui_to_settings(self):
        d = self.settings.data
        exp = d.setdefault("export", {})
        exp["format"] = self.combo_export_format.get() if hasattr(self, "combo_export_format") else "docx"
        exp["compress_images"] = self.var_compress_images.get() if hasattr(self, "var_compress_images") else True
        exp["pdf_password"] = self.entry_pdf_password.get() if hasattr(self, "entry_pdf_password") else ""
        exp["template"] = self.combo_templates.get() if hasattr(self, "combo_templates") else None

        ts = d.setdefault("table_style", {})
        ts["header_bold"] = self.var_header_bold.get() if hasattr(self, "var_header_bold") else True
        ts["alignment"] = self.combo_align.get() if hasattr(self, "combo_align") else "center"
        ts["font_name"] = self.entry_font_name.get() if hasattr(self, "entry_font_name") else "Calibri"
        try:
            ts["font_size"] = int(self.spin_font_size.get()) if hasattr(self, "spin_font_size") else 11
        except:
            ts["font_size"] = 11

        ts["border_style"] = self.combo_border_style.get() if hasattr(self, "combo_border_style") else "тонкая"

        ts["section_header_font_name"] = self.entry_sec_header_font_name.get() if hasattr(self, "entry_sec_header_font_name") else "Calibri"
        try:
            ts["section_header_font_size"] = int(self.spin_sec_header_font_size.get()) if hasattr(self, "spin_sec_header_font_size") else 12
        except:
            ts["section_header_font_size"] = 12
        ts["section_header_bold"] = self.var_sec_header_bold.get() if hasattr(self, "var_sec_header_bold") else True
        ts["section_header_align"] = self.combo_sec_header_align.get() if hasattr(self, "combo_sec_header_align") else "center"
        ts["section_header_bg_color"] = self.entry_sec_header_bg_color.get() if hasattr(self, "entry_sec_header_bg_color") else "FFFF00"

        self.settings.save()

    def load_settings_to_gui(self):
        d = self.settings.data
        ex = d.get("export", {})
        ts = d.get("table_style", {})

        if hasattr(self, "combo_export_format"):
            self.combo_export_format.set(ex.get("format", "docx"))
        if hasattr(self, "var_compress_images"):
            self.var_compress_images.set(ex.get("compress_images", True))
        if hasattr(self, "entry_pdf_password"):
            self.entry_pdf_password.delete(0, "end")
            self.entry_pdf_password.insert(0, ex.get("pdf_password", ""))

        if hasattr(self, "var_header_bold"):
            self.var_header_bold.set(ts.get("header_bold", True))
        if hasattr(self, "combo_align"):
            self.combo_align.set(ts.get("alignment", "center"))
        if hasattr(self, "entry_font_name"):
            self.entry_font_name.delete(0, "end")
            self.entry_font_name.insert(0, ts.get("font_name", "Calibri"))
        if hasattr(self, "spin_font_size"):
            self.spin_font_size.delete(0, "end")
            self.spin_font_size.insert(0, ts.get("font_size", 11))
        if hasattr(self, "combo_border_style"):
            self.combo_border_style.set(ts.get("border_style", "тонкая"))

        if hasattr(self, "entry_sec_header_font_name"):
            self.entry_sec_header_font_name.delete(0, "end")
            self.entry_sec_header_font_name.insert(0, ts.get("section_header_font_name", "Calibri"))
        if hasattr(self, "spin_sec_header_font_size"):
            self.spin_sec_header_font_size.delete(0, "end")
            self.spin_sec_header_font_size.insert(0, ts.get("section_header_font_size", 12))
        if hasattr(self, "var_sec_header_bold"):
            self.var_sec_header_bold.set(ts.get("section_header_bold", True))
        if hasattr(self, "combo_sec_header_align"):
            self.combo_sec_header_align.set(ts.get("section_header_align", "center"))
        if hasattr(self, "entry_sec_header_bg_color"):
            self.entry_sec_header_bg_color.delete(0, "end")
            self.entry_sec_header_bg_color.insert(0, ts.get("section_header_bg_color", "FFFF00"))

        if hasattr(self, "combo_templates"):
            self.load_templates()
            tmpl = ex.get("template")
            if self.templates:
                if tmpl in self.templates:
                    self.combo_templates.set(tmpl)
                    self.current_template = tmpl
                else:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def load_excel_dialog(self):
        path = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel Files", "*.xlsx *.xls")])
        if not path:
            return

        def task():
            try:
                self.logger.log(f"Загрузка файла: {path}")
                self.grouped_data = load_and_transform_excel_grouped(path)

                param_dict = {}
                for info in self.grouped_data.values():
                    for section in info['sections']:
                        for param in section['params']:
                            pname, pval = param['parameter'], param['value']
                            if pname not in param_dict:
                                param_dict[pname] = set()
                            param_dict[pname].add(pval)
                self.all_params = sorted(param_dict.keys())
                self.param_values = dict((k, sorted(v)) for k, v in param_dict.items())

                for row in self.param_filter_rows:
                    row['param']['values'] = self.all_params
                    p = row['param'].get()
                    if p and p in self.param_values:
                        row['value']['values'] = [''] + self.param_values[p]
                    else:
                        row['value']['values'] = []
                    row['value'].set('')

                rows = []
                for art, info in self.grouped_data.items():
                    obj = info["object_full"]
                    ptype = info["product_type"]
                    rows.append({"Объект": obj, "Тип товара": ptype, "Артикул": art})
                self.filtered_df = pd.DataFrame(rows)
                self.filtered_df_backup = self.filtered_df.copy()

                self.apply_all_filters()

                self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])

                self.after(0, self.update_basket_table)
                self.create_placeholders_from_loaded_products()

                self.after(0, lambda: messagebox.showinfo("Успех", f"Данные загружены: {len(self.filtered_df_backup)} записей"))
                self.logger.log(f"Файл загружен: {path}, записей: {len(self.filtered_df_backup)}")
            except Exception as e:
                self.after(0, lambda: messagebox.showerror("Ошибка загрузки", str(e)))
                self.logger.log(f"Ошибка загрузки: {e}")
                traceback.print_exc()

        threading.Thread(target=task, daemon=True).start()

    def create_placeholders_from_loaded_products(self):
        if not self.grouped_data:
            self.logger.log("Данные товаров отсутствуют - автоматическое создание плейсхолдеров пропущено")
            return

        unique_params = set()
        for info in self.grouped_data.values():
            for section in info['sections']:
                for param in section['params']:
                    unique_params.add(param['parameter'])

        updated = False
        for param in unique_params:
            if param not in self.user_placeholders:
                self.user_placeholders[param] = "{{" + param + "}}"
                updated = True

        if updated:
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            self.logger.log(f"Автоматически создано плейсхолдеров из параметров: {len(unique_params)}")
        else:
            self.logger.log("Автоматическое создание плейсхолдеров: новых плейсхолдеров не добавлено")

    def generate_document(self, path=None, show_messages=True):
        if self.basket_df.empty:
            if show_messages:
                messagebox.showwarning("Генерация", "Корзина пуста")
            return
        if not self.current_template:
            if show_messages:
                messagebox.showwarning("Генерация", "Шаблон не выбран")
            return

        self.save_gui_to_settings()

        if path is None:
            save_dir = filedialog.askdirectory(title="Выберите папку для сохранения документов")
            if not save_dir:
                return
        else:
            save_dir = os.path.dirname(path)

        def task():
            try:
                template_path = os.path.join(TEMPLATES_DIR, self.current_template)
                phs_global = self.user_placeholders.copy()
                unique_arts = list(dict.fromkeys(self.basket_df["Артикул"]))

                for art in unique_arts:
                    doc = Document(template_path)

                    if art in self.grouped_data:
                        info = self.grouped_data[art]
                        art_info = {
                            "Объект": info["object_full"],
                            "Тип товара": info["product_type"],
                            "Артикул": art,
                        }
                        art_sections = info["sections"]
                    else:
                        art_info = {"Объект": "", "Тип товара": "", "Артикул": art}
                        art_sections = []

                    phs = dict(phs_global)
                    phs.update(art_info)

                    for section in art_sections:
                        for param in section["params"]:
                            pname = param["parameter"]
                            pvalue = param["value"]
                            if pname in phs and phs[pname] == "{{" + pname + "}}":
                                phs[pname] = pvalue

                    for k, v in phs.items():
                        docx_replace_placeholder(doc, k, v)

                    placeholder_paragraph = None
                    for p in doc.paragraphs:
                        if "{{Таблица}}" in p.text:
                            placeholder_paragraph = p
                            break

                    if not placeholder_paragraph:
                        self.logger.log(f"Плейсхолдер {{Таблица}} не найден в шаблоне для артикула {art}")
                        if show_messages:
                            self.after(0, lambda art=art: messagebox.showwarning(
                                "Генерация", f"Шаблон не содержит плейсхолдера {{Таблица}} для артикула {art}"))
                        filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                        file_path = os.path.join(save_dir, filename)
                        doc.save(file_path)
                        continue

                    style = self.settings.data.get("table_style", {})
                    border_style = style.get("border_style", "тонкая")

                    previous_element = placeholder_paragraph._element

                    sh_font_name = style.get("section_header_font_name", "Calibri")
                    sh_font_size = style.get("section_header_font_size", 12)
                    sh_bold = style.get("section_header_bold", True)
                    sh_align = style.get("section_header_align", "center")
                    sh_bg_color = style.get("section_header_bg_color", "FFFF00")

                    for section in art_sections:
                        tbl = doc.add_table(rows=1, cols=2)
                        set_table_border(tbl, border_style)

                        row = tbl.rows[0]
                        cell = row.cells[0]
                        cell.merge(row.cells[1])
                        cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER

                        paragraph = cell.paragraphs[0]
                        paragraph.alignment = {
                            "left": WD_ALIGN_PARAGRAPH.LEFT,
                            "center": WD_ALIGN_PARAGRAPH.CENTER,
                            "right": WD_ALIGN_PARAGRAPH.RIGHT,
                        }.get(sh_align, WD_ALIGN_PARAGRAPH.CENTER)
                        for r in paragraph.runs:
                            r.text = ''
                        run = paragraph.add_run(section.get("header", ""))
                        run.font.name = sh_font_name
                        run.font.size = Pt(sh_font_size)
                        run.bold = sh_bold
                        set_cell_background(cell, sh_bg_color)

                        for param in section["params"]:
                            row_cells = tbl.add_row().cells
                            row_cells[0].text = str(param["parameter"])
                            row_cells[1].text = str(param["value"])
                            for i in (0, 1):
                                p_cell = row_cells[i].paragraphs[0]
                                p_cell.style = None
                                for r in p_cell.runs:
                                    r.font.name = style.get("font_name", "Calibri")
                                    r.font.size = Pt(style.get("font_size", 11))
                                    r.bold = False
                                p_cell.alignment = {
                                    "left": WD_ALIGN_PARAGRAPH.LEFT,
                                    "right": WD_ALIGN_PARAGRAPH.RIGHT,
                                    "center": WD_ALIGN_PARAGRAPH.CENTER,
                                }.get(style.get("alignment", "center"), WD_ALIGN_PARAGRAPH.CENTER)

                        previous_element.addnext(tbl._element)
                        previous_element = tbl._element

                    p = placeholder_paragraph._element
                    p.getparent().remove(p)

                    filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                    file_path = os.path.join(save_dir, filename)
                    doc.save(file_path)
                    self.logger.log(f"Создан файл для артикула {art}: {file_path}")

                    if self.settings.data.get("export", {}).get("format") == "pdf":
                        pdf_path = os.path.splitext(file_path)[0] + ".pdf"
                        try:
                            convert_docx_to_pdf(file_path, pdf_path)
                            self.logger.log(f"PDF создан для артикула {art}: {pdf_path}")
                        except Exception as e:
                            self.logger.log(f"Ошибка при создании PDF для артикула {art}: {e}")

                if show_messages:
                    self.after(0, lambda: messagebox.showinfo(
                        "Успех", f"Обработано товаров: {len(unique_arts)}\nФайлы сохранены в:\n{save_dir}"))

            except Exception as e:
                traceback.print_exc()
                self.logger.log(f"Ошибка генерации документа: {e}")
                if show_messages:
                    self.after(0, lambda: messagebox.showerror("Ошибка генерации", str(e)))

        threading.Thread(target=task, daemon=True).start()

    def context_menu_data(self, event):
        iid = self.tree_data.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Добавить в корзину", command=self.add_selected_to_basket)
        menu.tk_popup(event.x_root, event.y_root)
        menu.grab_release()

    def context_menu_basket(self, event):
        iid = self.tree_basket.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Удалить из корзины", command=self.remove_selected_from_basket)
        menu.tk_popup(event.x_root, event.y_root)
        menu.grab_release()

    def create_menu(self):
        menubar = tk.Menu(self)

        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Загрузить Excel", command=self.load_excel_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Сохранить лог...", command=self.export_log)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.quit)
        menubar.add_cascade(label="Файл", menu=file_menu)

        basket_menu = tk.Menu(menubar, tearoff=0)
        basket_menu.add_command(label="Удалить выделенные", command=self.remove_selected_from_basket)
        basket_menu.add_command(label="Очистить корзину", command=self.clear_basket)
        menubar.add_cascade(label="Корзина", menu=basket_menu)

        help_menu = tk.Menu(menubar, tearoff=0)
        help_menu.add_command(label="О программе", command=self.show_about)
        menubar.add_cascade(label="Помощь", menu=help_menu)

        self.config(menu=menubar)

    def bind_shortcuts(self):
        pass  # Можно добавить горячие клавиши

    def create_widgets(self):
        self.nb = ttk.Notebook(self)
        self.nb.grid(row=0, column=0, sticky="nsew")

        self.create_tab_data()
        self.create_tab_settings_gen()
        self.create_basket_frame()

    def create_tab_data(self):
        tab_data = ttk.Frame(self.nb)
        self.nb.add(tab_data, text="Данные")

        tab_data.grid_rowconfigure(1, weight=1)
        tab_data.grid_columnconfigure(0, weight=1)
        tab_data.grid_columnconfigure(1, weight=0)

        filter_frame = ttk.Frame(tab_data)
        filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        filter_frame.grid_columnconfigure(7, weight=1)

        ttk.Label(filter_frame, text="Поиск по артикулу:").grid(row=0, column=0, sticky="w")
        ent_quick_search = ttk.Entry(filter_frame, textvariable=self.var_quick_search)
        ent_quick_search.grid(row=0, column=1, sticky="ew", padx=3)
        filter_frame.grid_columnconfigure(1, weight=2)
        ent_quick_search.bind("<KeyRelease>", self.quick_search)

        ttk.Label(filter_frame, text="Фильтры параметров:").grid(row=0, column=2, sticky="w", padx=10)

        self.btn_add_param_filter = ttk.Button(filter_frame, text="+ Фильтр", command=self.add_param_filter_row)
        self.btn_add_param_filter.grid(row=0, column=7, sticky="e")

        self.param_filters_container = ttk.Frame(filter_frame)
        self.param_filters_container.grid(row=1, column=0, columnspan=8, sticky="ew", pady=5)
        self.param_filters_container.grid_columnconfigure(0, weight=1)

        self.param_filter_rows = []

        tbl_frame = ttk.Frame(tab_data)
        tbl_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        tbl_frame.grid_rowconfigure(0, weight=1)
        tbl_frame.grid_columnconfigure(0, weight=1)

        self.tree_data = ttk.Treeview(tbl_frame, show="headings", selectmode="extended")
        self.tree_data.grid(row=0, column=0, sticky="nsew")
        self.tree_data.bind("<Double-1>", self.add_selected_to_basket)
        self.tree_data.bind("<<TreeviewSelect>>", self.show_item_params)
        self.tree_data.bind("<Button-3>", self.context_menu_data)

        scr_y = ttk.Scrollbar(tbl_frame, orient="vertical", command=self.tree_data.yview)
        scr_y.grid(row=0, column=1, sticky="ns")

        scr_x = ttk.Scrollbar(tbl_frame, orient="horizontal", command=self.tree_data.xview)
        scr_x.grid(row=1, column=0, sticky="ew")

        self.tree_data.configure(yscrollcommand=scr_y.set, xscrollcommand=scr_x.set)

        right_frame = ttk.Frame(tab_data, relief="groove", borderwidth=2)
        right_frame.grid(row=1, column=1, sticky="nsew", padx=5, pady=5)
        tab_data.grid_columnconfigure(1, weight=0)
        right_frame.grid_rowconfigure(1, weight=1)
        right_frame.grid_columnconfigure(0, weight=1)

        ttk.Label(right_frame, text="Параметры выбранного товара:").grid(row=0, column=0, sticky="w", padx=5, pady=(0, 5))
        self.tree_params = ttk.Treeview(right_frame, show="headings", columns=["Параметр", "Значение"], height=20)
        self.tree_params.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.tree_params.heading("Параметр", text="Параметр")
        self.tree_params.heading("Значение", text="Значение")
        self.tree_params.column("Параметр", width=150, anchor="w")
        self.tree_params.column("Значение", width=150, anchor="w")

        sb_params_y = ttk.Scrollbar(right_frame, orient="vertical", command=self.tree_params.yview)
        sb_params_y.grid(row=1, column=1, sticky="ns")
        self.tree_params.configure(yscrollcommand=sb_params_y.set)

    def create_basket_frame(self):
        basket_frame = ttk.LabelFrame(self, text="Корзина товаров")
        basket_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.grid_rowconfigure(1, weight=1)

        basket_frame.grid_rowconfigure(0, weight=1)
        basket_frame.grid_columnconfigure(0, weight=1)

        self.tree_basket = ttk.Treeview(basket_frame, show="headings", selectmode="extended", height=6)
        self.tree_basket.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
        self.tree_basket.bind("<Delete>", lambda e: self.remove_selected_from_basket())
        self.tree_basket.bind("<Button-3>", self.context_menu_basket)

        sb_basket_y = ttk.Scrollbar(basket_frame, orient="vertical", command=self.tree_basket.yview)
        sb_basket_y.grid(row=0, column=1, sticky="ns", pady=5)

        sb_basket_x = ttk.Scrollbar(basket_frame, orient="horizontal", command=self.tree_basket.xview)
        sb_basket_x.grid(row=1, column=0, sticky="ew", padx=5)

        self.tree_basket.configure(yscrollcommand=sb_basket_y.set, xscrollcommand=sb_basket_x.set)

        ctrl_basket = ttk.Frame(basket_frame)
        ctrl_basket.grid(row=2, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        ctrl_basket.grid_columnconfigure(3, weight=1)

        ttk.Button(ctrl_basket, text="Удалить выделенные", command=self.remove_selected_from_basket).grid(row=0, column=0, padx=5)
        ttk.Button(ctrl_basket, text="Очистить корзину", command=self.clear_basket).grid(row=0, column=1, padx=5)
        ttk.Button(ctrl_basket, text="Генерировать документ", command=self.generate_document).grid(row=0, column=2, padx=5)

    def create_tab_settings_gen(self):
        tab = ttk.Frame(self.nb)
        self.nb.add(tab, text="Настройки и Генерация")

        tab.grid_columnconfigure(0, weight=1)
        tab.grid_rowconfigure(6, weight=1)

        prof_frame = ttk.LabelFrame(tab, text="Профили настроек")
        prof_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
        prof_frame.grid_columnconfigure(1, weight=1)

        self.combo_profiles = ttk.Combobox(prof_frame, state='readonly')
        self.combo_profiles.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
        self.combo_profiles.bind("<<ComboboxSelected>>", self.on_profile_change)

        ttk.Button(prof_frame, text="Загрузить профиль", command=self.load_profile).grid(row=0, column=1, padx=5)
        ttk.Button(prof_frame, text="Сохранить профиль", command=self.save_profile).grid(row=0, column=2, padx=5)
        ttk.Button(prof_frame, text="Обновить список", command=self.load_profiles).grid(row=0, column=3, padx=5)

        ttk.Label(tab, text="Выбор шаблона:").grid(row=1, column=0, sticky='w', padx=10, pady=(0, 5))
        self.combo_templates = ttk.Combobox(tab, state='readonly')
        self.combo_templates.grid(row=2, column=0, sticky="ew", padx=10)
        self.combo_templates.bind("<<ComboboxSelected>>", self.on_template_change)
        ttk.Button(tab, text="Предпросмотр шаблона", command=self.preview_template).grid(row=3, column=0, sticky='w', padx=10, pady=5)

        ttk.Label(tab, text="Плейсхолдеры:").grid(row=4, column=0, sticky='w', padx=10, pady=(5, 3))

        ph_container = ttk.Frame(tab)
        ph_container.grid(row=5, column=0, sticky="nsew", padx=10)
        ph_container.grid_columnconfigure(0, weight=1)
        ph_container.grid_rowconfigure(1, weight=1)

        search_frame = ttk.Frame(ph_container)
        search_frame.grid(row=0, column=0, sticky="ew")
        search_frame.columnconfigure(1, weight=1)

        ttk.Label(search_frame, text="Поиск:").grid(row=0, column=0, sticky="w")
        ent_ph_search = ttk.Entry(search_frame, textvariable=self.var_placeholder_search)
        ent_ph_search.grid(row=0, column=1, sticky="ew")
        ent_ph_search.bind("<KeyRelease>", self.on_placeholder_search)

        tree_frame = ttk.Frame(ph_container)
        tree_frame.grid(row=1, column=0, sticky="nsew", pady=(5, 0))
        tree_frame.grid_rowconfigure(0, weight=1)
        tree_frame.grid_columnconfigure(0, weight=1)

        self.tree_placeholders = ttk.Treeview(tree_frame, columns=("name", "value"), show='headings')
        self.tree_placeholders.grid(row=0, column=0, sticky="nsew")
        self.tree_placeholders.heading("name", text="Имя")
        self.tree_placeholders.heading("value", text="Значение")
        self.tree_placeholders.column("name", width=200, anchor="w")
        self.tree_placeholders.column("value", width=400, anchor="w")

        scrollbar_ph = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree_placeholders.yview)
        scrollbar_ph.grid(row=0, column=1, sticky="ns")
        self.tree_placeholders.configure(yscrollcommand=scrollbar_ph.set)

        ph_btn_frame = ttk.Frame(tab)
        ph_btn_frame.grid(row=7, column=0, sticky="ew", padx=10, pady=5)
        for i, (text, cmd) in enumerate([
            ("Добавить", self.add_placeholder),
            ("Редактировать", self.edit_placeholder),
            ("Удалить", self.delete_placeholder),
            ("Импорт из JSON", self.import_placeholders),
            ("Экспорт в JSON", self.export_placeholders),
        ]):
            ttk.Button(ph_btn_frame, text=text, command=cmd).grid(row=0, column=i, padx=5)

        style_label = ttk.Label(tab, text="Параметры таблицы:")
        style_label.grid(row=8, column=0, sticky='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab)
        style_frm.grid(row=9, column=0, sticky="ew", padx=10, pady=5)
        style_frm.columnconfigure(10, weight=1)

        self.var_header_bold.set(self.settings.data['table_style'].get('header_bold', True))
        ttk.Checkbutton(style_frm, text="Жирный заголовок", variable=self.var_header_bold).grid(row=0, column=0, padx=5, pady=2)

        ttk.Label(style_frm, text="Выравнивание:").grid(row=0, column=1, padx=5, pady=2, sticky='w')
        self.combo_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_align.set(self.settings.data['table_style'].get('alignment', 'center'))
        self.combo_align.grid(row=0, column=2, padx=5, pady=2)

        ttk.Label(style_frm, text="Шрифт:").grid(row=0, column=3, padx=5, pady=2, sticky='w')
        self.entry_font_name = ttk.Entry(style_frm, width=15)
        self.entry_font_name.insert(0, self.settings.data['table_style'].get('font_name', 'Calibri'))
        self.entry_font_name.grid(row=0, column=4, padx=5, pady=2)

        ttk.Label(style_frm, text="Размер:").grid(row=0, column=5, padx=5, pady=2, sticky='w')
        self.spin_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_font_size.delete(0, 'end')
        self.spin_font_size.insert(0, self.settings.data['table_style'].get('font_size', 11))
        self.spin_font_size.grid(row=0, column=6, padx=5, pady=2)

        ttk.Label(style_frm, text="Стиль рамки:").grid(row=0, column=7, padx=5, pady=2, sticky='w')
        self.combo_border_style = ttk.Combobox(style_frm, values=['нет', 'тонкая', 'толстая'], state='readonly', width=10)
        self.combo_border_style.set(self.settings.data['table_style'].get('border_style', 'тонкая'))
        self.combo_border_style.grid(row=0, column=8, padx=5, pady=2)

        ttk.Label(style_frm, text="Шрифт заголовка раздела:").grid(row=1, column=0, sticky='w', padx=5, pady=3)
        self.entry_sec_header_font_name = ttk.Entry(style_frm, width=15)
        self.entry_sec_header_font_name.grid(row=1, column=1, padx=5)
        self.entry_sec_header_font_name.insert(0, self.settings.data['table_style'].get("section_header_font_name", "Calibri"))

        ttk.Label(style_frm, text="Размер шрифта:").grid(row=1, column=2, sticky='w', padx=5, pady=3)
        self.spin_sec_header_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_sec_header_font_size.grid(row=1, column=3, padx=5)
        self.spin_sec_header_font_size.delete(0, "end")
        self.spin_sec_header_font_size.insert(0, self.settings.data['table_style'].get("section_header_font_size", 12))

        self.var_sec_header_bold.set(self.settings.data['table_style'].get("section_header_bold", True))
        ttk.Checkbutton(style_frm, text="Жирный заголовок раздела", variable=self.var_sec_header_bold).grid(row=1, column=4, padx=5)

        ttk.Label(style_frm, text="Выравнивание заголовка:").grid(row=1, column=5, sticky='w', padx=5, pady=3)
        self.combo_sec_header_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_sec_header_align.grid(row=1, column=6, padx=5)
        self.combo_sec_header_align.set(self.settings.data['table_style'].get("section_header_align", "center"))

        ttk.Label(style_frm, text="Цвет фона заголовка (HEX):").grid(row=1, column=7, sticky='w', padx=5, pady=3)
        self.entry_sec_header_bg_color = ttk.Entry(style_frm, width=12)
        self.entry_sec_header_bg_color.grid(row=1, column=8, padx=5)
        self.entry_sec_header_bg_color.insert(0, self.settings.data['table_style'].get("section_header_bg_color", "FFFF00"))

        export_label = ttk.Label(tab, text="Настройки экспорта:")
        export_label.grid(row=10, column=0, sticky='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab)
        export_frm.grid(row=11, column=0, sticky="ew", padx=10, pady=5)
        export_frm.grid_columnconfigure(8, weight=1)

        ttk.Label(export_frm, text="Формат файла:").grid(row=0, column=0, sticky="w", padx=5)
        self.combo_export_format = ttk.Combobox(export_frm, values=['docx', 'pdf'], state='readonly', width=10)
        self.combo_export_format.set(self.settings.data['export'].get('format', 'docx'))
        self.combo_export_format.grid(row=0, column=1, sticky="w", padx=5)

        self.var_compress_images.set(self.settings.data['export'].get('compress_images', True))
        ttk.Checkbutton(export_frm, text="Сжимать изображения", variable=self.var_compress_images).grid(row=0, column=2, sticky="w", padx=10)

        ttk.Label(export_frm, text="Пароль для PDF:").grid(row=0, column=3, sticky="w", padx=5)
        self.entry_pdf_password = ttk.Entry(export_frm, width=20, show='*')
        self.entry_pdf_password.insert(0, self.settings.data['export'].get('pdf_password', ''))
        self.entry_pdf_password.grid(row=0, column=4, sticky="w", padx=5)

        ttk.Button(tab, text="Сохранить настройки в профиль", command=self.save_profile).grid(row=12, column=0, sticky="e", padx=10, pady=10)

    def show_about(self):
        messagebox.showinfo("О программе", "Автоматизация техдокументации\nВерсия 1.0")


def main():
    app = App()
    app.mainloop()


if __name__ == '__main__':
    main()