у меня есть код:
from dialogs import *
from synonyms import *
from translation import *
from utils import *
from fileio import *
from constants import *
import tkinter as tk
from tkinter import Menu, Frame, LabelFrame, Label, Entry, Button, StringVar, LEFT, X, BOTH, SUNKEN, W, ttk, filedialog, simpledialog, messagebox, Scrollbar, RIGHT, BOTTOM, Toplevel, Canvas, END
import json
import pyperclip
import pandas as pd
import os
from bs4 import BeautifulSoup
import re
from functools import partial
import openpyxl
import collections

class AttributeEditor:
    MAPPINGS_FILE = 'mappings.json'
    EXCLUSIONS_FILE = 'exclusions.json'
    PROTECTED_VALUES_FILE = 'protected_values.json'
    SETTINGS_FILE = 'settings.json'
    CASE_NORM_FILE = 'case_normalization.json'
    REPLACEMENT_RULES_FILE = 'replacement_rules.json'
    PARAM_MERGE_SETTINGS_FILE = 'param_merge_settings.json'
    VALUE_MERGE_SETTINGS_FILE = 'value_merge_settings.json'

    def __init__(self, root):
        self.root = root
        self.root.title("Редактор атрибутов товаров")
        self.root.geometry("1200x700")
        self.original_df = None
        self.param_mapping = {}
        self.original_values = {}
        self.manual_split_values = {}
        self.exclusions = []
        self.mappings = {}
        self.sort_directions = {}
        self.edit_entry = None
        self.edit_undo_stack = []
        self.df_columns = []
        self.mapping_sort_reverse = False
        self._mapping_items_cache = []
        self.settings = {
            "features_col": "",
            "extra_cols": [],
            "manual_split_values": {},
            "source_file_path": "",
            "output_folder_path": ""
        }
        self.case_normalization = {}
        self.manual_split_results = {}
        self.replacement_rules = []
        self.protected_values = []
        self.synonyms_manager = ValueSynonymsManager()
        self.synonyms_manager.add_callback(self.dynamic_update_main_table)
        self.value_translation_rules_manager = ValueTranslationRulesManager()
        self.exclusions_manager = ExclusionsManager()
        self.value_translation_rules_manager.add_callback(self.dynamic_update_main_table)
        self.exclusions_manager.add_callback(self.dynamic_update_main_table)
        self.value_translation_rules = []
        self.translation_available = TRANSLATION_AVAILABLE
        if self.translation_available:
            self.translation_system = BuiltinTranslationSystem()
        else:
            self.translation_system = None
        self.column_history = {}  # Старая история (значения)
        self.column_op_history = {}  # Новая история операций по столбцам
        self.article_to_source_row = {}  # внутренний артикул -> индекс строки исходного файла
        self.load_settings()
        self.setup_ui()
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)
        self.load_column_history()

    def setup_ui(self):
        self.create_compact_menu()
        main_frame = Frame(self.root)
        main_frame.pack(fill=BOTH, expand=True, padx=5, pady=2)
        files_frame = LabelFrame(main_frame, text="Файлы и настройки", font=("Arial", 9, "bold"))
        files_frame.pack(fill=X, pady=(0, 2))
        file_row1 = Frame(files_frame)
        file_row1.pack(fill=X, padx=5, pady=2)
        # --- Файл ---
        Label(file_row1, text="Файл:").pack(side=LEFT)
        self.source_file_var = StringVar(value=self.settings.get("source_file_path", ""))
        source_entry = Entry(file_row1, textvariable=self.source_file_var)
        source_entry.pack(side=LEFT, fill=X, expand=True, padx=2)
        source_entry.bind('<FocusOut>', lambda e: self.save_settings())
        Button(file_row1, text="...", width=3, command=self.browse_source_file).pack(side=LEFT, padx=2)
        # --- Столбец ---
        Label(file_row1, text="Столбец:").pack(side=LEFT, padx=(15, 2))
        self.features_col = Entry(file_row1)
        self.features_col.pack(side=LEFT, padx=2)
        self.features_col.insert(0, self.settings.get("features_col", ""))
        # --- Доп.столбцы ---
        Label(file_row1, text="Доп.столбцы:").pack(side=LEFT, padx=(15, 2))
        self.extra_cols_label = Label(file_row1, text="", anchor=W, relief=SUNKEN, bg="white")
        self.extra_cols_label.pack(side=LEFT, fill=X, expand=True, padx=2)
        Button(file_row1, text="...", width=3, command=self.show_extra_cols_dialog).pack(side=LEFT, padx=2)
        file_row2 = Frame(files_frame)
        file_row2.pack(fill=X, padx=5, pady=(0, 2))
        Label(file_row2, text="Папка:", width=6).pack(side=LEFT)
        self.output_folder_var = StringVar(value=self.settings.get("output_folder_path", ""))
        output_entry = Entry(file_row2, textvariable=self.output_folder_var, width=35)
        output_entry.pack(side=LEFT, fill=X, expand=True, padx=2)
        output_entry.bind('<FocusOut>', lambda e: self.save_settings())
        Button(file_row2, text="...", width=3, command=self.browse_output_folder).pack(side=LEFT, padx=2)
        toolbar_frame = LabelFrame(main_frame, text="Действия", font=("Arial", 9, "bold"))
        toolbar_frame.pack(fill=X, pady=(0, 2))
        toolbar = Frame(toolbar_frame)
        toolbar.pack(fill=X, padx=5, pady=3)
        actions_frame = Frame(toolbar)
        actions_frame.pack(side=LEFT)
        self.btn_load = Button(actions_frame, text="Загрузить", command=self.load_file, width=12)
        self.btn_load.pack(side=LEFT, padx=2)
        # Кнопки экспорта рядом с загрузкой
        self.btn_save_xlsx = Button(actions_frame, text="Сохранить в XLSX", command=self.save_results, width=16)
        self.btn_save_xlsx.pack(side=LEFT, padx=2)
        self.btn_save_csv = Button(actions_frame, text="Сохранить в CSV", command=self.export_to_csv, width=16)
        self.btn_save_csv.pack(side=LEFT, padx=2)
        self.notebook = ttk.Notebook(main_frame)
        self.notebook.pack(fill=BOTH, expand=True, pady=2)
        self.main_tab = Frame(self.notebook)
        self.notebook.add(self.main_tab, text="Основная таблица")
        self.setup_main_tab()
        self.values_translation_tab = Frame(self.notebook)
        self.notebook.add(self.values_translation_tab, text="Переводы значений")
        self.setup_values_translation_tab()
        self.parameters_tab = Frame(self.notebook)
        self.notebook.add(self.parameters_tab, text="📝 Параметры")
        self.setup_parameters_tab()
        # Вкладка для проверки и исправления опечаток
        self.typos_tab = Frame(self.notebook)
        self.notebook.add(self.typos_tab, text="Опечатки")
        self.setup_typos_tab()
        self.info_label = Label(main_frame, text="Готов к работе", relief=SUNKEN, anchor=W, font=("Arial", 8), bg="#F0F0F0")
        self.info_label.pack(fill=X, pady=(2, 0))

    def create_compact_menu(self):
        menubar = Menu(self.root)
        self.root.config(menu=menubar)
        file_menu = Menu(menubar, tearoff=0)
        menubar.add_cascade(label="Файл", menu=file_menu)
        file_menu.add_command(label="Загрузить файл", command=self.load_file)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.on_close)
        self.root.bind('<Control-z>', self.undo_action)
        self.root.bind('<Control-Z>', self.undo_action)
        self.root.bind('<Control-y>', self.redo_action)
        self.root.bind('<Control-Y>', self.redo_action)
        self.root.bind('<Control-c>', self.copy_selected_cell_event)
        self.root.bind('<Control-C>', self.copy_selected_cell_event)
        self.root.bind('<Control-v>', self.paste_to_selected_cell)
        self.root.bind('<Control-V>', self.paste_to_selected_cell)
        self.root.bind('<Control-a>', self.select_all_cells)
        self.root.bind('<Control-A>', self.select_all_cells)
        self.root.bind('<Control-f>', self.focus_search_entry)
        self.root.bind('<Control-F>', self.focus_search_entry)

    def setup_main_tab(self):
        if hasattr(self, 'main_tab'):
            for widget in self.main_tab.winfo_children():
                widget.destroy()
            top_frame = Frame(self.main_tab)
            top_frame.pack(fill=X, padx=5, pady=5)
            # Поиск по артикулу
            Label(top_frame, text="Поиск по артикулу:").pack(side=LEFT)
            self.search_var = StringVar()
            Entry(top_frame, textvariable=self.search_var, width=20).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Найти", command=self.apply_article_search, width=7).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Сброс", command=self.reset_article_search, width=7).pack(side=LEFT, padx=(0,8))
            # Поиск по параметру
            Label(top_frame, text="Поиск по параметру:").pack(side=LEFT)
            self.param_search_var = StringVar()
            Entry(top_frame, textvariable=self.param_search_var, width=20).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Найти", command=self.apply_param_search, width=7).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Сброс", command=self.reset_param_search, width=7).pack(side=LEFT, padx=(0,8))
            # Выбор столбца и замена
            Label(top_frame, text="Столбец:").pack(side=LEFT, padx=(0,2))
            self.replace_col_var = StringVar()
            self.replace_col_menu = ttk.Combobox(top_frame, textvariable=self.replace_col_var, state='readonly', width=36)
            self.replace_col_menu.pack(side=LEFT, padx=(0,5))
            Button(top_frame, text="Заменить…", command=self.open_replace_column_dialog, width=10).pack(side=LEFT, padx=(0,2))
            # Кнопка истории столбца
            Button(top_frame, text="История столбца", command=self.open_column_history_dialog, width=16).pack(side=LEFT, padx=(0,8))
            # Кнопка настройки столбцов
            Button(top_frame, text="Настроить столбцы", command=self.open_column_visibility_dialog, width=16).pack(side=LEFT, padx=(0,8))
            # Таблица с горизонтальным и вертикальным скроллами
            table_frame = Frame(self.main_tab)
            table_frame.pack(fill=BOTH, expand=True, padx=5, pady=2)
            yscroll = Scrollbar(table_frame, orient='vertical')
            xscroll = Scrollbar(table_frame, orient='horizontal')
            self.tree = ttk.Treeview(table_frame, show='headings', yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
            yscroll.config(command=self.tree.yview)
            xscroll.config(command=self.tree.xview)
            yscroll.pack(side=RIGHT, fill=Y)
            xscroll.pack(side=BOTTOM, fill=X)
            self.tree.pack(side=LEFT, fill=BOTH, expand=True)
            self.tree.bind('<Control-C>', self.copy_selected_cell_event)
            self.tree.bind('<Double-1>', self.on_main_tree_double_click)
            self.tree.bind('<Button-3>', self.show_cell_menu)  # ПКМ
            self.tree.bind('<Control-c>', self.copy_cell_under_mouse_event)
            self.tree.bind('<Control-C>', self.copy_cell_under_mouse_event)
            self.cell_menu = tk.Menu(self.tree, tearoff=0)
            self.cell_menu.add_command(label="Копировать значение ячейки", command=self.copy_selected_cell)

    def setup_values_translation_tab(self):
        if hasattr(self, 'values_translation_tab'):
            for widget in self.values_translation_tab.winfo_children():
                widget.destroy()
            # --- КОМПАКТНЫЕ КНОПКИ НАВЕРХУ ---
            top_btns = Frame(self.values_translation_tab)
            top_btns.pack(fill=X, pady=5)
            Button(top_btns, text="Добавить", command=self.add_value_translation_rule_dialog, width=10).pack(side=LEFT, padx=2)
            Button(top_btns, text="Активировать все", command=self.activate_all_value_translations, width=14).pack(side=LEFT, padx=2)
            Button(top_btns, text="Деактивировать все", command=self.deactivate_all_value_translations, width=16).pack(side=LEFT, padx=2)
            # --- Canvas + Scrollbar для вертикального скролла ---
            canvas = Canvas(self.values_translation_tab)
            canvas.pack(side=LEFT, fill=BOTH, expand=True)
            vscroll = Scrollbar(self.values_translation_tab, orient='vertical', command=canvas.yview)
            vscroll.pack(side=RIGHT, fill=Y)
            canvas.configure(yscrollcommand=vscroll.set)
            inner_frame = Frame(canvas)
            canvas.create_window((0, 0), window=inner_frame, anchor='nw')
            def on_configure(event):
                canvas.configure(scrollregion=canvas.bbox('all'))
            inner_frame.bind('<Configure>', on_configure)
            # --- Новый блок: чекбоксы для каждой замены с кнопками ---
            import json
            import tkinter as tk
            from tkinter import simpledialog, messagebox
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            self.value_translation_vars = []
            cb_frame = Frame(inner_frame)
            cb_frame.pack(fill=X, pady=5)
            Label(cb_frame, text="Для редактирования или удаления используйте кнопки рядом с правилом.", fg="blue").pack(anchor='w', pady=(0,5))
            for i, rule in enumerate(rules):
                row = Frame(cb_frame)
                row.pack(fill=X, pady=1)
                var = tk.BooleanVar(value=rule.get('active', True))
                cb = tk.Checkbutton(row, text=f"{rule['original']} → {rule['translation']} ({rule.get('description','')})", variable=var,
                                   command=partial(self.toggle_value_translation_rule, i, var))
                cb.pack(side=LEFT)
                # Кнопка редактирования
                def make_edit(idx):
                    return lambda: self.edit_value_translation_rule_by_index(idx)
                btn_edit = Button(row, text="✎", command=make_edit(i), width=2)
                btn_edit.pack(side=LEFT, padx=2)
                # Кнопка удаления
                def make_delete(idx):
                    return lambda: self.delete_value_translation_rule_by_index(idx)
                btn_del = Button(row, text="✖", command=make_delete(i), width=2, fg='red')
                btn_del.pack(side=LEFT, padx=2)
                self.value_translation_vars.append(var)
            self.value_translation_rules = rules

    def delete_value_translation_rule_by_index(self, idx):
        import json
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        if 0 <= idx < len(rules):
            del rules[idx]
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()

    def add_value_translation_rule_dialog(self):
        import json
        from tkinter import simpledialog
        orig = simpledialog.askstring("Новое правило", "Исходное значение (единица):")
        if not orig:
            return
        trans = simpledialog.askstring("Новое правило", "На что заменить:")
        if trans is None:
            return
        desc = simpledialog.askstring("Новое правило", "Описание (необязательно):")
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        rules.append({'original': orig, 'translation': trans, 'description': desc or '', 'active': True})
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def edit_value_translation_rule_dialog(self):
        import json
        from tkinter import simpledialog, messagebox
        # Определяем выбранный индекс (по первому отмеченному чекбоксу)
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is None:
            messagebox.showinfo("Редактировать правило", "Сначала выберите правило для редактирования.")
            return
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        rule = rules[idx]
        orig = simpledialog.askstring("Редактировать правило", "Исходное значение (единица):", initialvalue=rule['original'])
        if not orig:
            return
        trans = simpledialog.askstring("Редактировать правило", "На что заменить:", initialvalue=rule['translation'])
        if trans is None:
            return
        desc = simpledialog.askstring("Редактировать правило", "Описание (необязательно):", initialvalue=rule.get('description',''))
        rules[idx] = {'original': orig, 'translation': trans, 'description': desc or '', 'active': rule.get('active', True)}
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def toggle_value_translation_rule(self, idx, var):
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        rules[idx]['active'] = var.get()
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.value_translation_rules = rules
        # Можно добавить автообновление основной таблицы при изменении
        self.fill_main_table_from_characteristics()

    def activate_all_value_translations(self):
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        for rule in rules:
            rule['active'] = True
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def deactivate_all_value_translations(self):
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        for rule in rules:
            rule['active'] = False
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def load_settings(self):
        self.settings = load_settings(self.SETTINGS_FILE)

    def save_settings(self):
        save_settings(self.settings, self.SETTINGS_FILE)

    def load_file(self):
        file_path = self.source_file_var.get().strip()
        if not file_path:
            self.info_label.config(text="Ошибка: путь к файлу не указан")
            return
        if not os.path.exists(file_path):
            self.info_label.config(text=f"Ошибка: файл не найден: {file_path}")
            return
        try:
            self.original_df = pd.read_excel(file_path)
            self.df_columns = self.original_df.columns.tolist()
            # --- Автоопределение столбца с html-таблицами ---
            html_col = None
            for col in self.original_df.columns:
                sample = self.original_df[col].astype(str).head(10).str.contains('<table|<tr', case=False, na=False).any()
                if sample:
                    html_col = col
                    break
            if html_col:
                self.features_col.delete(0, 'end')
                self.features_col.insert(0, html_col)
                self.settings['features_col'] = html_col
            self.settings['source_file_path'] = file_path
            self.save_settings()
            self.fill_main_table_from_characteristics()
            self.update_extra_cols_label()
            self.setup_parameters_tab()
            # --- Применяем все активные изменения из истории к DataFrame ---
            if hasattr(self, 'column_history') and hasattr(self, 'column_history_active'):
                for col, history in self.column_history.items():
                    active_flags = self.column_history_active.get(col, [True]*len(history))
                    active_indices = [i for i, v in enumerate(active_flags) if v]
                    if active_indices:
                        # Применяем последнее активное состояние
                        self.current_df[col] = history[active_indices[-1]]
            self.refresh_main_table_from_df()
            self.info_label.config(text="Файл успешно загружен")
        except Exception as e:
            self.info_label.config(text=f"Ошибка загрузки файла: {e}")

    def parse_html_table_wide(self, html):
        soup = BeautifulSoup(html, 'html.parser')
        trs = soup.find_all('tr')
        if not trs:
            return [], []
        # Получаем артикулы из первой строки
        first_row = trs[0]
        ths = first_row.find_all(['td', 'th'])
        articles = [td.get_text('\n', strip=True) for td in ths[1:]]  # Все ячейки после первой
        if not articles:
            # Если нет ячеек после первой, проверяем другие строки
            for tr2 in trs[1:]:
                tds2 = tr2.find_all(['td', 'th'])
                if len(tds2) > 1:
                    articles = [td.get_text('\n', strip=True) for td in tds2[1:]]
                    break
        n_articles = len(articles)
        # Собираем параметры и значения
        params = []
        values_by_article = {art: {} for art in articles}
        for tr in trs[1:]:
            tds = tr.find_all(['td', 'th'])
            # Пропускаем заголовки разделов (colspan >= 3 или пустой первый столбец)
            if len(tds) == 1 and (tds[0].has_attr('colspan') and int(tds[0]['colspan']) >= 3):
                continue
            if len(tds) > 0 and (tds[0].get_text(strip=True) == '' or (tds[0].has_attr('colspan') and int(tds[0]['colspan']) >= 3)):
                continue
            param = tds[0].get_text('\n', strip=True) if tds else ''
            if not param:
                continue
            if param not in params:
                params.append(param)
            # Значения для артикулов
            # Корректное определение td_idx для строк с/без параметра
            if tds and tds[0].get_text('\n', strip=True):
                td_idx = 1
            else:
                td_idx = 0
            values = []
            art_idx = 0
            while art_idx < n_articles and td_idx < len(tds):
                td = tds[td_idx]
                colspan = int(td.get('colspan', 1))
                value = td.get_text('\n', strip=True)
                for _ in range(colspan):
                    if art_idx < n_articles:
                        values.append(value)
                        art_idx += 1
                td_idx += 1
            while len(values) < n_articles:
                values.append('')
            for art, val in zip(articles, values):
                values_by_article[art][param] = val
        return articles, params, values_by_article

    def fill_main_table_from_characteristics(self):
        # Маркер: начало функции
        try:
            if not hasattr(self, 'tree') or self.original_df is None:
                return
            self.tree.delete(*self.tree.get_children())
            char_col = None
            for col in self.original_df.columns:
                if 'характеристик' in col.lower():
                    char_col = col
                    break
            if char_col is None:
                self.info_label.config(text="Столбец 'Характеристики' не найден")
                return
            all_articles = []
            all_params = []
            all_values = {}
            section_headers = set()
            for idx, row in self.original_df.iterrows():
                html = str(row[char_col])
                soup = BeautifulSoup(html, 'html.parser')
                trs = soup.find_all('tr')
                if not trs:
                    continue
                first_row = trs[0]
                ths = first_row.find_all(['td', 'th'])
                articles = [td.get_text(strip=True) for td in ths[1:]]
                if len(articles) < 1:
                    continue
                art1 = articles[0]
                art2 = articles[1] if len(articles) > 1 else None
                for art in articles:
                    if art not in all_articles:
                        all_articles.append(art)
                    self.article_to_source_row[art] = idx
                values_by_article = {art: {} for art in articles}
                trs_list = list(trs[1:])
                i = 0
                while i < len(trs_list):
                    tr = trs_list[i]
                    tds = tr.find_all(['td', 'th'])
                    # Пропуск заголовков разделов
                    if len(tds) == 1 and int(tds[0].get('colspan', 1)) == len(articles)+1:
                        i += 1
                        continue
                    # rowspan + colspan: собираем значения из N строк (первая строка — вторая ячейка, остальные — первая)
                    if len(tds) == 2 and tds[0].has_attr('rowspan'):
                        param = tds[0].get_text(strip=True)
                        rowspan = int(tds[0]['rowspan'])
                        vals = []
                        # Собираем значения из всех строк rowspan
                        for j in range(rowspan):
                            current_i = i + j
                            if current_i >= len(trs_list):
                                break
                            current_tr = trs_list[current_i]
                            current_tds = current_tr.find_all(['td', 'th'])
                            if not current_tds:
                                continue
                            # В первой строке значение во второй ячейке, в остальных — в первой
                            if j == 0:
                                # Проверяем, есть ли вторая ячейка
                                if len(current_tds) > 1:
                                    val = current_tds[1].get_text("\n", strip=True)
                                else:
                                    val = current_tds[0].get_text("\n", strip=True)
                            else:
                                # В последующих строках значение в первой ячейке (которая на самом деле вторая в таблице)
                                if len(current_tds) >= 1:
                                    val = current_tds[0].get_text("\n", strip=True)
                                else:
                                    val = ''
                            # --- Новое: разбиваем по переводам строк и объединяем через ', ' ---
                            if val.strip():
                                val_lines = [v.strip() for v in val.split("\n") if v.strip()]
                                vals.extend(val_lines)
                        if vals:
                            val_joined = ', '.join(vals)
                            for art in articles:
                                if param not in values_by_article[art] or not values_by_article[art][param]:
                                    # Если val_joined содержит \n, разбить и объединить через ', '
                                    if '\n' in val_joined:
                                        val_joined = ', '.join([v.strip() for v in val_joined.split('\n') if v.strip()])
                                    values_by_article[art][param] = val_joined
                                    with open("debug_parsing.txt", "a", encoding="utf-8") as f:
                                        f.write(f"Article: {art}, Param: {param}, Value: {val_joined}\n")
                            if param not in all_params:
                                all_params.append(param)
                            i += rowspan
                            continue
                    # 2 ячейки: параметр + общее значение (colspan=2)
                    if len(tds) == 2:
                        param = tds[0].get_text(strip=True)
                        val = tds[1].get_text("\n", strip=True)
                        # --- Новое: разбиваем по переводам строк и объединяем через ', ' ---
                        val_lines = [v.strip() for v in val.split("\n") if v.strip()]
                        val = ', '.join(val_lines)
                        colspan = int(tds[1].get('colspan', 1))
                        # Если значение для всех артикулов
                        if colspan >= len(articles):
                            for art in articles:
                                if param not in values_by_article[art] or not values_by_article[art][param]:
                                    # Если val содержит \n, разбить и объединить через ', '
                                    if '\n' in val:
                                        val = ', '.join([v.strip() for v in val.split('\n') if v.strip()])
                                    values_by_article[art][param] = val
                                    with open("debug_parsing.txt", "a", encoding="utf-8") as f:
                                        f.write(f"Article: {art}, Param: {param}, Value: {val}\n")
                            if param not in all_params:
                                all_params.append(param)
                            i += 1
                            continue
                        # Если значение для части артикулов
                        elif colspan > 1:
                            for art_idx in range(colspan):
                                if art_idx < len(articles):
                                    art = articles[art_idx]
                                    if param not in values_by_article[art] or not values_by_article[art][param]:
                                        # Если val содержит \n, разбить и объединить через ', '
                                        if '\n' in val:
                                            val = ', '.join([v.strip() for v in val.split('\n') if v.strip()])
                                        values_by_article[art][param] = val
                                        with open("debug_parsing.txt", "a", encoding="utf-8") as f:
                                            f.write(f"Article: {art}, Param: {param}, Value: {val}\n")
                            if param not in all_params:
                                all_params.append(param)
                            i += 1
                            continue
                        else:
                            # Если нет colspan, только для первого артикула
                            if art1:
                                if param not in values_by_article[art1] or not values_by_article[art1][param]:
                                    # Если val содержит \n, разбить и объединить через ', '
                                    if '\n' in val:
                                        val = ', '.join([v.strip() for v in val.split('\n') if v.strip()])
                                    values_by_article[art1][param] = val
                                    with open("debug_parsing.txt", "a", encoding="utf-8") as f:
                                        f.write(f"Article: {art1}, Param: {param}, Value: {val}\n")
                            if param not in all_params:
                                all_params.append(param)
                            i += 1
                            continue
                    # 3+ ячеек: параметр + значения для каждого артикула (или групп артикулов)
                    if len(tds) > 2:
                        param = tds[0].get_text(strip=True)
                        art_idx = 0
                        for val_td in tds[1:]:
                            val = val_td.get_text("\n", strip=True)
                            # --- Новое: разбиваем по переводам строк и объединяем через ', ' ---
                            val_lines = [v.strip() for v in val.split("\n") if v.strip()]
                            val = ', '.join(val_lines)
                            colspan = int(val_td.get('colspan', 1))
                            for k in range(colspan):
                                if art_idx < len(articles):
                                    art = articles[art_idx]
                                    if param not in values_by_article[art] or not values_by_article[art][param]:
                                        # Если val содержит \n, разбить и объединить через ', '
                                        if '\n' in val:
                                            val = ', '.join([v.strip() for v in val.split('\n') if v.strip()])
                                        values_by_article[art][param] = val
                                        with open("debug_parsing.txt", "a", encoding="utf-8") as f:
                                            f.write(f"Article: {art}, Param: {param}, Value: {val}\n")
                                    art_idx += 1
                        if param not in all_params:
                            all_params.append(param)
                        i += 1
                        continue
                    i += 1
                # После разбора одной таблицы — переносим значения в all_values
                for art in articles:
                    if art not in all_values:
                        all_values[art] = {}
                    for param, val in values_by_article[art].items():
                        all_values[art][param] = [val] if val else ['']
            all_params = [p for p in all_params if p not in section_headers and p.strip()]
            data = []
            # --- Получаем активные правила перевода значений ---
            import re, json, os
            value_translation_rules = []
            if os.path.exists('value_translation_rules.json'):
                with open('value_translation_rules.json', encoding='utf-8') as f:
                    value_translation_rules = [r for r in json.load(f) if r.get('active', True)]
            def apply_value_translations(val):
                v = str(val)
                for rule in self.value_translation_rules:
                    if rule.get('active', True):
                        orig = rule['original']
                        repl = rule['translation']
                        # Замена только если orig — единица измерения (после числа, пробела или в конце слова)
                        # Например: 30 V, 10cm, 0.002 Lux
                        v = re.sub(rf'(\d[\d., ]*|\b)({re.escape(orig)})\b', lambda m: m.group(1) + repl, v, flags=re.IGNORECASE)
                return v
            for art in all_articles:
                row = {'Артикул': art}
                for p in all_params:
                    vals = all_values.get(art, {}).get(p, [])
                    # --- Удаление дублей с нормализацией ---
                    unique_vals = remove_duplicates_normalized(vals)
                    joined = ', '.join([apply_value_translations(self.translation_system.translate_text(clean_value_enhanced(v), is_parameter=False)) for v in unique_vals])
                    row[p] = joined
                data.append(row)
            df = pd.DataFrame(data)
            non_empty_cols = ['Артикул'] + [col for col in all_params if df[col].astype(str).str.strip().replace('nan','').any()]
            df = df[non_empty_cols]
            columns = list(df.columns)
            self.last_table_columns = columns
            self.visible_columns = columns.copy()
            self.tree['columns'] = columns
            self.tree['show'] = 'headings'
            for col in columns:
                self.tree.heading(col, text=col)
                max_len = max([len(str(x)) for x in [col] + list(df[col])] + [10])
                width = min(max_len * 10, 800)
                self.tree.column(col, width=width, minwidth=80, stretch=False)
            for i, row in df.iterrows():
                values = [row[col] for col in columns]
                self.tree.insert('', 'end', values=values)
            self.current_df = df
            # --- Автоматически применяем объединения параметров из param_merge_rules.json ---
            param_merge_rules = []
            if os.path.exists('param_merge_rules.json'):
                with open('param_merge_rules.json', encoding='utf-8') as f:
                    param_merge_rules = json.load(f)
            for rule in param_merge_rules:
                params = rule.get('params') or rule.get('parameters')
                target = rule.get('target')
                if params and target:
                    self.perform_param_merge(params, target)
            # Автоматически объединяем дублирующиеся столбцы по нормализованному имени
            self.merge_duplicate_columns_by_normalized_name()
            self.setup_parameters_tab()
            self.init_param_translations_from_tree()
            self.refresh_main_table_headers_with_translations()
            # После загрузки данных обновлять вкладку опечаток
            if hasattr(self, 'setup_typos_tab'):
                self.setup_typos_tab()
        except Exception as e:
            pass

    def merge_duplicate_columns_by_normalized_name(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        norm_to_cols = {}
        for col in df.columns:
            norm = normalize_param_name(col)
            norm_to_cols.setdefault(norm, []).append(col)
        for norm, cols in norm_to_cols.items():
            if len(cols) > 1:
                merged = df[cols].astype(str).apply(lambda row: '; '.join(sorted(set([v for v in row if v and v != 'nan']))), axis=1)
                df[norm] = merged
                for c in cols:
                    if c != norm and c in df.columns:
                        df.drop(columns=[c], inplace=True)
        self.current_df = df
        self.refresh_main_table_from_df()
        self.refresh_main_table_headers_with_translations()

    def save_results(self):
        if not hasattr(self, 'original_df') or self.original_df is None:
            self.info_label.config(text="Нет данных для сохранения")
            return
        html_col = self.features_col.get().strip()
        if html_col not in self.original_df.columns:
            self.info_label.config(text="Ошибка: столбец характеристик не найден")
            return
        extra_col_names = self.settings.get("extra_cols", [])
        available_extra_cols = [col for col in extra_col_names if col in self.original_df.columns]
        # --- Собираем итоговый DataFrame для выгрузки ---
        result_rows = []
        for i, row in self.current_df.iterrows():
            result_row = {col: row[col] for col in self.current_df.columns if col != 'Артикул'}
            art = row['Артикул'] if 'Артикул' in row else None
            if art and hasattr(self, 'article_to_source_row') and art in self.article_to_source_row:
                src_idx = self.article_to_source_row[art]
                src_row = self.original_df.iloc[src_idx]
                result_row['Артикул'] = src_row['Артикул'] if 'Артикул' in src_row else ''
                for col in available_extra_cols:
                    result_row[col] = src_row[col] if col in src_row else ''
            else:
                result_row['Артикул'] = art if art else ''
                for col in available_extra_cols:
                    result_row[col] = ''
            result_rows.append(result_row)
        if not result_rows:
            self.info_label.config(text="Нет данных для выгрузки")
            return
        df_out = pd.DataFrame(result_rows)
        # --- Используем переводы параметров для выгрузки ---
        translations = getattr(self, 'param_translations', None)
        if translations:
            df_out.rename(columns={col: translations.get(normalize_param_name(col), col) for col in df_out.columns}, inplace=True)
        out_path = os.path.join(os.path.dirname(self.settings.get('source_file_path', '')), 'woocommerce_ready.xlsx')
        try:
            df_out.to_excel(out_path, index=False)
            self.info_label.config(text=f"Файл сохранён: {out_path}")
        except Exception as e:
            self.info_label.config(text=f"Ошибка при сохранении: {e}")

    def open_translation_dialog(self):
        BuiltinTranslationDialog(self.root, self.translation_system)

    def open_replacement_rules_editor(self):
        ReplacementRulesEditorDialog(self.root, self.replacement_rules, self.save_settings)

    def open_case_normalization_dialog(self):
        CaseNormalizationDialog(self.root, self.case_normalization, self.save_settings)

    def open_value_synonyms_dialog(self):
        ValueSynonymsDialog(self.root, self.synonyms_manager)

    def open_protected_values_dialog(self):
        if 'ProtectedValuesDialog' in globals():
            ProtectedValuesDialog(self.root)
        else:
            from tkinter import messagebox
            messagebox.showinfo("Защищённые значения", "Здесь будет диалог для защищённых значений.")

    def open_exclusions_dialog(self):
        ExclusionsDialog(self.root, self.exclusions_manager)

    def show_extra_cols_dialog(self):
        if not hasattr(self, 'df_columns') or not self.df_columns:
            messagebox.showwarning("Ошибка", "Сначала загрузите файл")
            return
        current_selection = set(self.settings.get('extra_cols', []))
        dialog = Toplevel(self.root)
        dialog.title("Выбор дополнительных столбцов")
        dialog.geometry("400x500")
        vars = {}
        frame = Frame(dialog)
        frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        for col in self.df_columns:
            var = tk.BooleanVar(value=col in current_selection)
            chk = Checkbutton(frame, text=f"{self.df_columns.index(col)+1}. {col}", variable=var)
            chk.pack(anchor=W)
            vars[col] = var
        def apply():
            selected = [col for col, v in vars.items() if v.get()]
            self.settings['extra_cols'] = selected
            self.update_extra_cols_label()
            self.save_settings()
            dialog.destroy()
        Button(dialog, text="Применить", command=apply).pack(pady=10)

    def browse_source_file(self):
        filename = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")])
        if filename:
            self.source_file_var.set(filename)
            self.save_settings()

    def browse_output_folder(self):
        folder = filedialog.askdirectory(title="Выберите папку для сохранения")
        if folder:
            self.output_folder_var.set(folder)
            self.save_settings()

    def refresh_mapping_table(self):
        if hasattr(self, 'tree') and hasattr(self, 'mappings'):
            self.tree.delete(*self.tree.get_children())
            for i, (orig, repl) in enumerate(self.mappings.items(), 1):
                self.tree.insert('', 'end', values=(i, orig, '', repl, '', ''))

    def dynamic_update_main_table(self):
        self.refresh_mapping_table()
        self.info_label.config(text="Таблица обновлена (callback)")

    def on_close(self):
        self.save_settings()
        # --- Автосохранение переводов параметров ---
        if hasattr(self, 'save_param_translations') and hasattr(self, 'param_translations') and hasattr(self, 'current_df'):
            try:
                self.save_param_translations()
            except Exception as e:
                print('Ошибка автосохранения переводов:', e)
        self.root.destroy()

    def copy_selected_value(self):
        selected = self.tree.selection()
        if selected:
            value = self.tree.item(selected[0])['values']
            pyperclip.copy(str(value))
            self.info_label.config(text="Значение скопировано в буфер обмена")
        else:
            self.info_label.config(text="Нет выбранной строки для копирования")

    def show_cell_menu(self, event):
        region = self.tree.identify('region', event.x, event.y)
        if region == 'cell':
            rowid = self.tree.identify_row(event.y)
            col = self.tree.identify_column(event.x)
            self.tree.selection_set(rowid)
            self.tree.focus(rowid)
            self._cell_menu_row = rowid
            self._cell_menu_col = col
            self.cell_menu.post(event.x_root, event.y_root)

    def copy_selected_cell(self):
        try:
            rowid = getattr(self, '_cell_menu_row', None)
            col = getattr(self, '_cell_menu_col', None)
            if rowid and col:
                col_idx = int(col.replace('#', '')) - 1
                values = self.tree.item(rowid)['values']
                if 0 <= col_idx < len(values):
                    pyperclip.copy(str(values[col_idx]))
        except Exception:
            pass

    def copy_cell_under_mouse_event(self, event=None):
        # Копировать значение ячейки под мышью или выделенной
        try:
            x = self.tree.winfo_pointerx() - self.tree.winfo_rootx()
            y = self.tree.winfo_pointery() - self.tree.winfo_rooty()
            rowid = self.tree.identify_row(y)
            col = self.tree.identify_column(x)
            if rowid and col:
                col_idx = int(col.replace('#', '')) - 1
                values = self.tree.item(rowid)['values']
                if 0 <= col_idx < len(values):
                    pyperclip.copy(str(values[col_idx]))
                    self.info_label.config(text="Значение ячейки скопировано")
                    return
            # fallback: если не удалось, копируем первую ячейку выделенной строки
            selected = self.tree.selection()
            if selected:
                values = self.tree.item(selected[0])['values']
                if values:
                    pyperclip.copy(str(values[0]))
                    self.info_label.config(text="Значение ячейки скопировано")
        except Exception:
            pass

    def copy_selected_cell_event(self, event=None):
        try:
            selected = self.tree.selection()
            if not selected:
                return
            rowid = selected[0]
            focus_col = self.tree.focus()
            # Получаем выделенную ячейку (по фокусу или первой колонке)
            col = self.tree.identify_column(event.x) if event and hasattr(event, 'x') else '#1'
            col_idx = int(col.replace('#', '')) - 1
            values = self.tree.item(rowid)['values']
            if 0 <= col_idx < len(values):
                pyperclip.copy(str(values[col_idx]))
        except Exception:
            pass

    def open_replace_column_dialog(self):
        col = self.replace_col_var.get()
        if not col or not hasattr(self, 'current_df') or self.current_df is None:
            return
        dialog = Toplevel(self.root)
        dialog.title(f"Замена в столбце: {col}")
        dialog.geometry("400x200")
        Label(dialog, text=f"Столбец: {col}").pack(pady=5)
        find_var = StringVar()
        replace_var = StringVar()
        regex_var = IntVar()
        frame = Frame(dialog)
        frame.pack(pady=10)
        Label(frame, text="Что заменить:").grid(row=0, column=0, sticky=W)
        Entry(frame, textvariable=find_var, width=30).grid(row=0, column=1)
        Label(frame, text="На что заменить:").grid(row=1, column=0, sticky=W)
        Entry(frame, textvariable=replace_var, width=30).grid(row=1, column=1)
        Checkbutton(dialog, text="Использовать регулярное выражение", variable=regex_var).pack()
        Button(dialog, text="Заменить", command=lambda: self.apply_column_replace(dialog, col, find_var.get(), replace_var.get(), regex_var.get())).pack(pady=10)

    def apply_column_replace(self, dialog, col, find, replace, use_regex):
        import re
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df.copy()
        orig_col = self.get_original_column_name(col)
        def norm(s): return ''.join(s.lower().split())
        real_col = None
        if orig_col:
            for c in df.columns:
                if c == orig_col:
                    real_col = c
                    break
            if not real_col:
                for c in df.columns:
                    if c.lower() == orig_col.lower():
                        real_col = c
                        break
            if not real_col:
                ncol = norm(orig_col)
                for c in df.columns:
                    if norm(c) == ncol:
                        real_col = c
                        break
        if not real_col:
            for c in df.columns:
                if c == col:
                    real_col = c
                    break
        if not real_col:
            for c in df.columns:
                if c.lower() == col.lower():
                    real_col = c
                    break
        if not real_col:
            ncol = norm(col)
            for c in df.columns:
                if norm(c) == ncol:
                    real_col = c
                    break
        if not real_col or real_col not in df.columns:
            from tkinter import messagebox
            real_cols = list(df.columns)
            msg = f'Столбец "{col}" не найден в таблице!\n\nТекущие столбцы:\n' + '\n'.join(real_cols)
            print('DEBUG: Все столбцы DataFrame:', real_cols)
            messagebox.showerror('Ошибка', msg)
            dialog.destroy()
            return
        # Применяем замену только по точному совпадению (без глобального re.sub)
        if use_regex:
            df[real_col] = df[real_col].astype(str).apply(lambda x: re.sub(find, replace, x, flags=re.IGNORECASE))
        else:
            df[real_col] = df[real_col].astype(str).apply(lambda x: x.replace(find, replace))
        # --- Сохраняем историю изменений ---
        if real_col not in self.column_op_history:
            self.column_op_history[real_col] = []
        op_descr = f"Замена: '{find}' → '{replace}' (regex: {bool(use_regex)})"
        self.column_op_history[real_col].append(op_descr)
        if real_col not in self.column_history:
            self.column_history[real_col] = []
        self.column_history[real_col].append(list(df[real_col]))
        if not hasattr(self, 'column_history_active'):
            self.column_history_active = {}
        if real_col not in self.column_history_active:
            self.column_history_active[real_col] = []
        self.column_history_active[real_col].append(True)
        self.save_column_history()
        self.current_df = df
        self.refresh_main_table_from_df()
        self.save_undo_state()
        # --- АВТОСОХРАНЕНИЕ DataFrame после замены ---
        if hasattr(self, 'source_file_var') and self.source_file_var.get():
            file_path = self.source_file_var.get()
            try:
                if file_path.lower().endswith('.xlsx'):
                    self.current_df.to_excel(file_path, index=False)
                else:
                    self.current_df.to_csv(file_path, index=False, encoding='utf-8-sig')
                self.info_label.config(text=f'Изменения сохранены в {file_path}')
            except Exception as e:
                print('Ошибка автосохранения:', e)
        dialog.destroy()

    def get_original_column_name(self, selected_col):
        df = self.current_df
        translations = getattr(self, 'param_translations', None)
        # 1. Поиск по переводу → оригинал
        if translations:
            for orig, tr in translations.items():
                if tr == selected_col:
                    return orig
        # 2. Регистр-независимо
        if translations:
            for orig, tr in translations.items():
                if tr.lower() == selected_col.lower():
                    return orig
        # 3. Нормализованно
        def norm(s): return ''.join(s.lower().split())
        ncol = norm(selected_col)
        if translations:
            for orig, tr in translations.items():
                if norm(tr) == ncol:
                    return orig
        # 4. По всем столбцам DataFrame
        for c in df.columns:
            if c == selected_col or c.lower() == selected_col.lower() or norm(c) == ncol:
                return c
        return None

    def apply_article_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        search_text = self.search_var.get().strip().lower()
        if not search_text:
            self.reset_article_search()
            return
        df = self.current_df
        filtered = df[df['Артикул'].str.lower().str.contains(search_text, na=False)]
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        for i, row in filtered.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)

    def reset_article_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        for i, row in df.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)

    def apply_param_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        search_text = self.param_search_var.get().strip().lower()
        if not search_text:
            self.reset_param_search()
            return
        df = self.current_df
        # Фильтруем столбцы по подстроке
        filtered_cols = [col for col in df.columns if search_text in col.lower()]
        if not filtered_cols:
            self.tree.delete(*self.tree.get_children())
            return
        self.tree.delete(*self.tree.get_children())
        self.tree['columns'] = filtered_cols
        self.tree['show'] = 'headings'
        for col in filtered_cols:
            self.tree.heading(col, text=col)
            max_len = max([len(str(x)) for x in [col] + list(df[col])] + [10])
            width = min(max_len * 10, 800)
            self.tree.column(col, width=width, minwidth=80, stretch=False)
        for i, row in df.iterrows():
            values = [row[col] for col in filtered_cols]
            self.tree.insert('', 'end', values=values)

    def reset_param_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        self.tree['columns'] = columns
        self.tree['show'] = 'headings'
        for col in columns:
            self.tree.heading(col, text=col)
            max_len = max([len(str(x)) for x in [col] + list(df[col])] + [10])
            width = min(max_len * 10, 800)
            self.tree.column(col, width=width, minwidth=80, stretch=False)
        for i, row in df.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)
        self.refresh_main_table_headers_with_translations()

    def open_column_visibility_dialog(self):
        dialog = Toplevel(self.root)
        dialog.title("Настройка видимости столбцов")
        dialog.geometry("400x500")
        translations = getattr(self, 'param_translations', None)
        # --- Только реально отображаемые столбцы, без дублей ---
        columns = list(self.current_df.columns)
        seen = set()
        display_to_col = {}
        display_columns = []
        for col in columns:
            display = translations.get(normalize_param_name(col), col) if translations else col
            if display not in seen:
                display_columns.append(display)
                display_to_col[display] = col
                seen.add(display)
        # Загружаем настройки видимости
        visible_columns = set(self.visible_columns) if hasattr(self, 'visible_columns') else set(columns)
        if os.path.exists('visible_columns.json'):
            try:
                with open('visible_columns.json', 'r', encoding='utf-8') as f:
                    saved = json.load(f)
                visible_columns = set(saved)
            except Exception:
                pass
        vars = {}
        btns = Frame(dialog)
        btns.pack(fill=X, pady=10)
        def select_all():
            for v in vars.values():
                v.set(1)
        def deselect_all():
            for v in vars.values():
                v.set(0)
        Button(btns, text="Выделить все", command=select_all).pack(side=LEFT, padx=5)
        Button(btns, text="Снять все", command=deselect_all).pack(side=LEFT, padx=5)
        def apply():
            self.visible_columns = [display_to_col[disp] for disp, v in vars.items() if v.get()]
            self.tree['displaycolumns'] = self.visible_columns
            # Сохраняем настройки видимости
            try:
                with open('visible_columns.json', 'w', encoding='utf-8') as f:
                    json.dump(self.visible_columns, f, ensure_ascii=False, indent=2)
            except Exception:
                pass
            dialog.destroy()
        Button(btns, text="Применить", command=apply).pack(side=LEFT, padx=5)
        canvas = Canvas(dialog)
        canvas.pack(side=LEFT, fill=BOTH, expand=True)
        scrollbar = Scrollbar(dialog, orient=VERTICAL, command=canvas.yview)
        scrollbar.pack(side=RIGHT, fill=Y)
        canvas.configure(yscrollcommand=scrollbar.set)
        inner_frame = Frame(canvas)
        canvas.create_window((0, 0), window=inner_frame, anchor='nw')
        def on_configure(event):
            canvas.configure(scrollregion=canvas.bbox('all'))
        inner_frame.bind('<Configure>', on_configure)
        for i, display in enumerate(display_columns):
            col = display_to_col[display]
            var = IntVar(value=1 if col in visible_columns else 0)
            vars[display] = var
            Checkbutton(inner_frame, text=display, variable=var).pack(anchor=W)

    def open_column_history_dialog(self):
        col = self.replace_col_var.get()
        orig_col = self.get_original_column_name(col)
        df = self.current_df
        def norm(s): return ''.join(s.lower().split())
        real_col = None
        if orig_col:
            for c in df.columns:
                if c == orig_col:
                    real_col = c
                    break
            if not real_col:
                for c in df.columns:
                    if c.lower() == orig_col.lower():
                        real_col = c
                        break
            if not real_col:
                ncol = norm(orig_col)
                for c in df.columns:
                    if norm(c) == ncol:
                        real_col = c
                        break
        if not real_col:
            for c in df.columns:
                if c == col:
                    real_col = c
                    break
        if not real_col:
            for c in df.columns:
                if c.lower() == col.lower():
                    real_col = c
                    break
        if not real_col:
            ncol = norm(col)
            for c in df.columns:
                if norm(c) == ncol:
                    real_col = c
                    break
        # --- Исправление: если не нашли real_col, пробуем по переводу ---
        if not real_col and hasattr(self, 'param_translations'):
            for orig, tr in self.param_translations.items():
                if tr == col or tr.lower() == col.lower() or norm(tr) == norm(col):
                    if orig in df.columns:
                        real_col = orig
                        break
        if not real_col or real_col not in self.column_history:
            self.info_label.config(text=f'Нет истории для столбца {col}')
            return
        history = self.column_history[real_col]
        active_flags = self.column_history_active.get(real_col, [True]*len(history))
        dialog = Toplevel(self.root)
        dialog.title(f"История изменений: {col}")
        dialog.geometry("800x500")
        Label(dialog, text=f"История изменений столбца: {col}").pack(pady=5)
        frame = Frame(dialog)
        frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        # --- Список операций с чекбоксом и крестиком на одной строке ---
        op_frame = Frame(frame)
        op_frame.pack(fill=BOTH, expand=True)
        check_vars = []
        for idx, op in enumerate(self.column_op_history.get(real_col, [])):
            row = Frame(op_frame)
            row.pack(fill=X, pady=2)
            var = tk.BooleanVar(value=active_flags[idx] if idx < len(active_flags) else True)
            def make_toggle(idx_toggle, var=var):
                return lambda *_: toggle_active(idx_toggle, var)
            chk = Checkbutton(row, variable=var, command=make_toggle(idx, var))
            chk.pack(side=LEFT)
            Label(row, text=f"{idx+1}. {op}", anchor='w').pack(side=LEFT, fill=X, expand=True)
            def make_delete(idx_del):
                return lambda: delete_op(idx_del)
            btn = Button(row, text="✖", command=make_delete(idx), width=2, fg='red')
            btn.pack(side=RIGHT)
            check_vars.append(var)
        def toggle_active(idx_toggle, var):
            self.column_history_active[real_col][idx_toggle] = var.get()
            self.save_column_history()
            # Автоматически применяем изменения
            active_indices = [i for i, v in enumerate(self.column_history_active[real_col]) if v]
            if active_indices:
                self.current_df[real_col] = self.column_history[real_col][active_indices[-1]]
            else:
                # Если все деактивированы, откатываем к начальному состоянию (первое сохранённое)
                if self.column_history[real_col]:
                    self.current_df[real_col] = self.column_history[real_col][0]
            self.refresh_main_table_from_df()
        def delete_op(idx_del):
            if real_col in self.column_op_history and idx_del < len(self.column_op_history[real_col]):
                del self.column_op_history[real_col][idx_del]
            if real_col in self.column_history and idx_del < len(self.column_history[real_col]):
                del self.column_history[real_col][idx_del]
            if real_col in self.column_history_active and idx_del < len(self.column_history_active[real_col]):
                del self.column_history_active[real_col][idx_del]
            self.save_column_history()
            dialog.destroy()
            self.open_column_history_dialog()
        def clear_all():
            self.column_op_history[real_col] = []
            self.column_history[real_col] = []
            self.column_history_active[real_col] = []
            self.save_column_history()
            # Откатить столбец к начальному состоянию (до всех изменений)
            if hasattr(self, 'original_df') and real_col in self.original_df.columns:
                self.current_df[real_col] = self.original_df[real_col]
            self.refresh_main_table_from_df()
            dialog.destroy()
        Button(dialog, text="Очистить все", command=clear_all, fg='red').pack(pady=10)

    # --- Реализация undo/redo и универсальных действий ---
    def undo_action(self, event=None):
        if not hasattr(self, 'undo_stack'):
            self.undo_stack = []
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        if self.undo_stack:
            last_state = self.undo_stack.pop()
            if hasattr(self, 'current_df'):
                self.redo_stack.append(self.current_df.copy())
                self.current_df = last_state
                self.refresh_main_table_from_df()
            # Для других вкладок
            if hasattr(self, 'active_tab') and self.active_tab == 'params_merge':
                if hasattr(self, 'params_merge_undo_stack') and self.params_merge_undo_stack:
                    state = self.params_merge_undo_stack.pop()
                    self.params_merge_redo_stack.append(self.param_merge_rules_manager.rules.copy())
                    self.param_merge_rules_manager.rules = state
                    self.setup_params_merge_tab()
            if hasattr(self, 'active_tab') and self.active_tab == 'values_translation':
                if hasattr(self, 'values_translation_undo_stack') and self.values_translation_undo_stack:
                    state = self.values_translation_undo_stack.pop()
                    self.values_translation_redo_stack.append(self.value_translation_rules.copy())
                    self.value_translation_rules = state
                    self.setup_values_translation_tab()

    def redo_action(self, event=None):
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        if self.redo_stack:
            state = self.redo_stack.pop()
            if hasattr(self, 'current_df'):
                self.save_undo_state()
                self.current_df = state
                self.refresh_main_table_from_df()
            # Для других вкладок
            if hasattr(self, 'active_tab') and self.active_tab == 'params_merge':
                if hasattr(self, 'params_merge_redo_stack') and self.params_merge_redo_stack:
                    state = self.params_merge_redo_stack.pop()
                    self.save_params_merge_undo_state()
                    self.param_merge_rules_manager.rules = state
                    self.setup_params_merge_tab()
            if hasattr(self, 'active_tab') and self.active_tab == 'values_translation':
                if hasattr(self, 'values_translation_redo_stack') and self.values_translation_redo_stack:
                    state = self.values_translation_redo_stack.pop()
                    self.save_values_translation_undo_state()
                    self.value_translation_rules = state
                    self.setup_values_translation_tab()

    def save_undo_state(self):
        if not hasattr(self, 'undo_stack'):
            self.undo_stack = []
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        import copy
        self.undo_stack.append(copy.deepcopy(self.current_df))
        self.redo_stack.clear()

    # Для других вкладок:
    def save_params_merge_undo_state(self):
        if not hasattr(self, 'params_merge_undo_stack'):
            self.params_merge_undo_stack = []
        if not hasattr(self, 'params_merge_redo_stack'):
            self.params_merge_redo_stack = []
        import copy
        self.params_merge_undo_stack.append(copy.deepcopy(self.param_merge_rules_manager.rules))
        self.params_merge_redo_stack.clear()

    def save_values_translation_undo_state(self):
        if not hasattr(self, 'values_translation_undo_stack'):
            self.values_translation_undo_stack = []
        if not hasattr(self, 'values_translation_redo_stack'):
            self.values_translation_redo_stack = []
        import copy
        self.values_translation_undo_stack.append(copy.deepcopy(self.value_translation_rules))
        self.values_translation_redo_stack.clear()

    def copy_selected_cell_event(self, event=None):
        # Копирование выделенных ячеек/строк в Excel-совместимом формате (TSV)
        import pyperclip
        selected = self.tree.selection()
        if not selected:
            return
        columns = self.tree['columns']
        data = []
        for item in selected:
            row = self.tree.item(item, 'values')
            data.append('\t'.join(str(row[i]) for i in range(len(columns))))
        pyperclip.copy('\n'.join(data))

    def paste_to_selected_cell(self, event=None):
        import pyperclip
        value = pyperclip.paste()
        rows = value.split('\n')
        selected = self.tree.selection()
        if not selected:
            return
        columns = self.tree['columns']
        for idx, item in enumerate(selected):
            if idx < len(rows):
                row_values = list(self.tree.item(item, 'values'))
                pasted = rows[idx].split('\t')
                for col_idx in range(min(len(pasted), len(row_values))):
                    # --- Сохраняем историю операций ---
                    col = columns[col_idx]
                    if col not in self.column_op_history:
                        self.column_op_history[col] = []
                    old_val = row_values[col_idx]
                    new_val = pasted[col_idx]
                    op_descr = f"Вставка из буфера: строка {idx}, '{old_val}' → '{new_val}'"
                    self.column_op_history[col].append(op_descr)
                    if col not in self.column_history:
                        self.column_history[col] = []
                    self.column_history[col].append(list(self.current_df[col]))
                    self.save_undo_state()
                    row_values[col_idx] = pasted[col_idx]
                self.tree.item(item, values=row_values)

    def select_all_cells(self, event=None):
        for item in self.tree.get_children():
            self.tree.selection_add(item)

    def focus_search_entry(self, event=None):
        # Фокус на поле поиска артикула
        if hasattr(self, 'search_var'):
            for widget in self.main_tab.winfo_children():
                if isinstance(widget, Frame):
                    for child in widget.winfo_children():
                        if isinstance(child, Entry):
                            child.focus_set()
                            return

    def refresh_main_table_from_df(self):
        # Перерисовать основную таблицу из self.current_df
        if not hasattr(self, 'tree') or self.current_df is None:
            return
        self.tree.delete(*self.tree.get_children())
        columns = list(self.current_df.columns)
        self.tree['columns'] = columns
        self.tree['show'] = 'headings'
        translations = getattr(self, 'param_translations', None)
        for col in columns:
            display = translations.get(normalize_param_name(col), col) if translations else col
            self.tree.heading(col, text=display)
            max_len = max([len(str(x)) for x in [display] + list(self.current_df[col])] + [10])
            width = min(max_len * 10, 800)
            self.tree.column(col, width=width, minwidth=80, stretch=False)
        for i, row in self.current_df.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)
        self.refresh_main_table_headers_with_translations()
        self.update_replace_col_menu()
        # --- Восстанавливаем видимость столбцов из файла ---
        if os.path.exists('visible_columns.json'):
            try:
                with open('visible_columns.json', 'r', encoding='utf-8') as f:
                    visible_columns = json.load(f)
                self.visible_columns = [col for col in visible_columns if col in self.current_df.columns]
                self.tree['displaycolumns'] = self.visible_columns
            except Exception:
                self.visible_columns = list(self.current_df.columns)
                self.tree['displaycolumns'] = self.visible_columns
        else:
            self.visible_columns = list(self.current_df.columns)
            self.tree['displaycolumns'] = self.visible_columns

    def on_main_tree_double_click(self, event):
        region = self.tree.identify('region', event.x, event.y)
        if region != 'cell':
            return
        rowid = self.tree.identify_row(event.y)
        col = self.tree.identify_column(event.x)
        if not rowid or not col:
            return
        col_idx = int(col.replace('#', '')) - 1
        columns = self.tree['columns']
        values = list(self.tree.item(rowid, 'values'))
        old_value = values[col_idx]
        entry = Entry(self.tree)
        entry.insert(0, old_value)
        entry.select_range(0, END)
        entry.focus_set()
        x, y, width, height = self.tree.bbox(rowid, col)
        entry.place(x=x, y=y, width=width, height=height)
        def save_and_destroy(event=None):
            new_value = entry.get().strip()
            if new_value != old_value:
                values[col_idx] = new_value
                self.tree.item(rowid, values=values)
                # Сохраняем в DataFrame
                idx = self.tree.index(rowid)
                col_name = columns[col_idx]
                self.current_df.at[self.current_df.index[idx], col_name] = new_value
                # --- Сохраняем историю операций ---
                if col_name not in self.column_op_history:
                    self.column_op_history[col_name] = []
                op_descr = f"Ручное редактирование: строка {idx}, '{old_value}' → '{new_value}'"
                self.column_op_history[col_name].append(op_descr)
                if col_name not in self.column_history:
                    self.column_history[col_name] = []
                self.column_history[col_name].append(list(self.current_df[col_name]))
            entry.destroy()
        entry.bind('<Return>', save_and_destroy)
        entry.bind('<FocusOut>', save_and_destroy)

    def update_extra_cols_label(self):
        # Обновляет отображение выбранных доп. столбцов с номерами
        extra_cols = self.settings.get('extra_cols', [])
        if hasattr(self, 'df_columns') and self.df_columns:
            display_text = ', '.join([
                f"{self.df_columns.index(col)+1}. {col}" if col in self.df_columns else col
                for col in extra_cols
            ]) if extra_cols else ''
        else:
            display_text = ', '.join(extra_cols) if extra_cols else ''
        self.extra_cols_label.config(text=display_text)

    def add_value_translation_rule(self):
        from dialogs import ValueTranslationRuleDialog
        dialog = ValueTranslationRuleDialog(self.root)
        if dialog.result:
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            rules.append({"active": True, **dialog.result})
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()

    def edit_value_translation_rule(self):
        from dialogs import ValueTranslationRuleDialog
        # Определяем выбранный индекс (по первому отмеченному чекбоксу)
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is not None:
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            dialog = ValueTranslationRuleDialog(self.root, rules[idx])
            if dialog.result:
                rules[idx].update(dialog.result)
                with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                    json.dump(rules, f, ensure_ascii=False, indent=2)
                self.setup_values_translation_tab()
                self.fill_main_table_from_characteristics()

    def delete_value_translation_rule(self):
        # Удаляет первое отмеченное правило
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is not None:
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            del rules[idx]
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()

    def setup_parameters_tab(self):
        for widget in self.parameters_tab.winfo_children():
            widget.destroy()
        columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
        norm_map = {}
        for col in columns:
            norm = normalize_param_name(col)
            if norm not in norm_map:
                norm_map[norm] = col
        norm_names = sorted(norm_map.keys(), key=lambda x: x.lower())
        btns_frame = Frame(self.parameters_tab)
        btns_frame.pack(fill=X, padx=10, pady=5)
        Button(btns_frame, text="Экспорт в JSON", command=self.export_params_json).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Импорт из JSON", command=self.import_params_json).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Экспорт в XLSX", command=self.export_params_xlsx).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Импорт из XLSX", command=self.import_params_xlsx).pack(side=LEFT, padx=2)
        Label(self.parameters_tab, text="Перевод в столбце 'Перевод параметра' формируется автоматически через BuiltinTranslationSystem (title case + словарь переводов), но вы можете вручную отредактировать перевод двойным кликом.", fg="blue").pack(fill=X, padx=10, pady=(0,5))
        tree_frame = Frame(self.parameters_tab)
        tree_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        self.param_tree = ttk.Treeview(tree_frame, columns=("param", "translation"), show="headings", height=20, selectmode="extended")
        self.param_tree.heading("param", text="Исходные параметры")
        self.param_tree.heading("translation", text="Перевод параметра")
        self.param_tree.column("param", width=250, anchor=W)
        self.param_tree.column("translation", width=250, anchor=W)
        self.param_tree.pack(side=LEFT, fill=BOTH, expand=True)
        vsb = Scrollbar(tree_frame, orient="vertical", command=self.param_tree.yview)
        self.param_tree.configure(yscrollcommand=vsb.set)
        vsb.pack(side=RIGHT, fill=Y)
        self.param_translation_vars = {}
        for norm in norm_names:
            orig = norm_map[norm]
            translation = self.translation_system.translate_text(norm, is_parameter=True) if self.translation_system else norm
            self.param_tree.insert("", "end", values=(norm, translation))
            self.param_translation_vars[norm] = translation
        self.param_tree.bind('<Double-1>', self.on_param_tree_double_click)
        self.param_menu = tk.Menu(self.param_tree, tearoff=0)
        self.param_menu.add_command(label="Объединить параметры и их значения", command=self.merge_selected_params)
        self.param_tree.bind('<Button-3>', self.show_param_context_menu)
        self.load_param_translations_from_file()
        self.init_param_translations_from_tree()
        self.refresh_main_table_headers_with_translations()

    def load_param_translations_from_file(self):
        import os, json
        file = 'param_translations.json'
        if os.path.exists(file):
            with open(file, 'r', encoding='utf-8') as f:
                translations = json.load(f)
            # Обновляем дерево параметров
            for item in self.param_tree.get_children():
                param = self.param_tree.set(item, "param")
                if param in translations:
                    self.param_tree.set(item, "translation", translations[param])
            self.param_translations = translations
        else:
            self.param_translations = {self.param_tree.set(item, "param"): self.param_tree.set(item, "translation") for item in self.param_tree.get_children()}

    def init_param_translations_from_tree(self):
        # Инициализирует self.param_translations из текущего дерева параметров
        if hasattr(self, 'param_tree'):
            self.param_translations = {self.param_tree.set(item, "param"): self.param_tree.set(item, "translation") for item in self.param_tree.get_children()}

    def on_param_tree_double_click(self, event):
        item = self.param_tree.identify_row(event.y)
        column = self.param_tree.identify_column(event.x)
        if not item or column != '#2':
            return
        x, y, width, height = self.param_tree.bbox(item, column)
        value = self.param_tree.set(item, "translation")
        entry = Entry(self.param_tree)
        entry.insert(0, value)
        entry.select_range(0, END)
        entry.focus_set()
        entry.place(x=x, y=y, width=width, height=height)
        def save_edit(event=None):
            new_value = entry.get().strip()
            self.param_tree.set(item, "translation", new_value)
            param = self.param_tree.set(item, "param")
            self.param_translation_vars[param] = new_value
            entry.destroy()
            self.save_param_translations()  # автосохранение
        entry.bind('<Return>', save_edit)
        entry.bind('<FocusOut>', save_edit)

    def save_param_translations(self):
        # Сохраняет переводы параметров из дерева
        import json
        self.param_translations = {self.param_tree.set(item, "param"): self.param_tree.set(item, "translation") for item in self.param_tree.get_children()}
        with open('param_translations.json', 'w', encoding='utf-8') as f:
            json.dump(self.param_translations, f, ensure_ascii=False, indent=2)
        if hasattr(self, 'info_label') and self.info_label:
            self.info_label.config(text="Переводы параметров сохранены")
        self.refresh_main_table_headers_with_translations()
        self.update_replace_col_menu()  # обновляем выпадающий список

    def refresh_main_table_headers_with_translations(self):
        # Обновляет заголовки столбцов основной таблицы на переводы
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        columns = list(df.columns)
        # Получаем переводы
        translations = getattr(self, 'param_translations', None)
        if not translations:
            return
        # Меняем заголовки в treeview
        self.tree['columns'] = columns
        self.tree['show'] = 'headings'
        for col in columns:
            display = translations.get(normalize_param_name(col), col)
            self.tree.heading(col, text=display)
            max_len = max([len(str(x)) for x in [display] + list(df[col])] + [10])
            width = min(max_len * 10, 800)
            self.tree.column(col, width=width, minwidth=80, stretch=False)
        self.update_replace_col_menu()  # обновляем выпадающий список

    def edit_value_translation_rule_by_index(self, idx):
        import json
        from tkinter import messagebox
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        if idx < 0 or idx >= len(rules):
            messagebox.showinfo("Редактировать правило", "Некорректный индекс правила.")
            return
        rule = rules[idx]
        # --- Новый диалог ---
        dialog = Toplevel(self.root)
        dialog.title("Редактировать правило")
        dialog.geometry("350x180")
        dialog.grab_set()
        Label(dialog, text="Исходное значение (единица):").pack(pady=(12,2), anchor='w', padx=12)
        entry_orig = Entry(dialog)
        entry_orig.pack(fill='x', padx=12)
        entry_orig.insert(0, rule['original'])
        Label(dialog, text="Перевод:").pack(pady=(10,2), anchor='w', padx=12)
        entry_trans = Entry(dialog)
        entry_trans.pack(fill='x', padx=12)
        entry_trans.insert(0, rule['translation'])
        Label(dialog, text="Описание (необязательно):").pack(pady=(10,2), anchor='w', padx=12)
        entry_desc = Entry(dialog)
        entry_desc.pack(fill='x', padx=12)
        entry_desc.insert(0, rule.get('description',''))
        def on_ok():
            orig = entry_orig.get()
            trans = entry_trans.get()
            desc = entry_desc.get()
            if not orig or not trans:
                messagebox.showwarning("Ошибка", "Заполните оба поля: исходное значение и перевод.")
                return
            rules[idx] = {'original': orig, 'translation': trans, 'description': desc or '', 'active': rule.get('active', True)}
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            dialog.destroy()
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()
        def on_cancel():
            dialog.destroy()
        btns = Frame(dialog)
        btns.pack(pady=12)
        Button(btns, text="OK", width=10, command=on_ok).pack(side=LEFT, padx=8)
        Button(btns, text="Cancel", width=10, command=on_cancel).pack(side=LEFT, padx=8)
        entry_orig.focus_set()

    def show_param_context_menu(self, event):
        selection = self.param_tree.selection()
        if len(selection) >= 2:
            self.param_menu.tk_popup(event.x_root, event.y_root)

    def merge_selected_params(self):
        selection = self.param_tree.selection()
        if len(selection) < 2:
            return
        params = [self.param_tree.set(item, "param") for item in selection]
        # Диалог выбора итогового названия
        top = Toplevel(self.root)
        top.title("Выберите итоговое название параметра")
        Label(top, text="Выберите название, которое останется после объединения:").pack(padx=10, pady=10)
        var = tk.StringVar(value=params[0])
        for p in params:
            Radiobutton(top, text=p, variable=var, value=p).pack(anchor=W, padx=10)
        def do_merge():
            target = var.get()
            self.perform_param_merge(params, target)
            top.destroy()
            self.save_param_translations()  # автосохранение после объединения
        Button(top, text="Объединить", command=do_merge).pack(pady=10)

    def perform_param_merge(self, params, target):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df.copy()
        norm_to_orig = {normalize_param_name(col): col for col in df.columns}
        merged_values = []
        for p in params:
            orig = norm_to_orig.get(normalize_param_name(p), p)
            if orig in df.columns:
                merged_values.append(df[orig].astype(str))
        if merged_values:
            import pandas as pd
            merged = pd.Series(['; '.join(set([v for v in row if v and v != 'nan'])) for row in zip(*merged_values)], index=df.index)
            df[target] = merged
        for p in params:
            orig = norm_to_orig.get(normalize_param_name(p), p)
            if orig != target and orig in df.columns:
                df.drop(columns=[orig], inplace=True)
        self.current_df = df
        self.refresh_main_table_from_df()
        self.setup_parameters_tab()
        self.refresh_main_table_headers_with_translations()
        self.info_label.config(text=f"Параметры объединены: {', '.join(params)} → {target}")
        self.save_param_translations()  # автосохранение после объединения

    def export_params_json(self):
        import json
        params = [{
            'param': self.param_tree.set(item, 'param'),
            'translation': self.param_tree.set(item, 'translation')
        } for item in self.param_tree.get_children()]
        file = filedialog.asksaveasfilename(defaultextension='.json', filetypes=[('JSON files', '*.json')])
        if file:
            with open(file, 'w', encoding='utf-8') as f:
                json.dump(params, f, ensure_ascii=False, indent=2)
            self.info_label.config(text=f'Экспортировано в {file}')
            self.save_param_translations()  # автосохранение после экспорта

    def import_params_json(self):
        import json
        file = filedialog.askopenfilename(filetypes=[('JSON files', '*.json')])
        if file:
            with open(file, 'r', encoding='utf-8') as f:
                params = json.load(f)
            self.param_tree.delete(*self.param_tree.get_children())
            for row in params:
                self.param_tree.insert('', 'end', values=(row['param'], row['translation']))
            self.info_label.config(text=f'Импортировано из {file}')
            self.save_param_translations()  # автосохранение после импорта

    def export_params_xlsx(self):
        import openpyxl
        from openpyxl import Workbook
        params = [{
            'param': self.param_tree.set(item, 'param'),
            'translation': self.param_tree.set(item, 'translation')
        } for item in self.param_tree.get_children()]
        file = filedialog.asksaveasfilename(defaultextension='.xlsx', filetypes=[('Excel files', '*.xlsx')])
        if file:
            wb = Workbook()
            ws = wb.active
            ws.append(['Исходные параметры', 'Перевод параметра'])
            for row in params:
                ws.append([row['param'], row['translation']])
            wb.save(file)
            self.info_label.config(text=f'Экспортировано в {file}')
            self.save_param_translations()  # автосохранение после экспорта

    def import_params_xlsx(self):
        import openpyxl
        file = filedialog.askopenfilename(filetypes=[('Excel files', '*.xlsx')])
        if file:
            wb = openpyxl.load_workbook(file)
            ws = wb.active
            rows = list(ws.iter_rows(values_only=True))
            self.param_tree.delete(*self.param_tree.get_children())
            for row in rows[1:]:
                self.param_tree.insert('', 'end', values=(row[0], row[1]))
            self.info_label.config(text=f'Импортировано из {file}')
            self.save_param_translations()  # автосохранение после импорта

    def setup_typos_tab(self):
        import json, os
        for widget in self.typos_tab.winfo_children():
            widget.destroy()
        Label(self.typos_tab, text="Пользовательские правила исправления опечаток", font=("Arial", 12, "bold")).pack(pady=10)
        # --- Загрузка правил ---
        typo_rules_file = 'typo_rules.json'
        if os.path.exists(typo_rules_file):
            with open(typo_rules_file, encoding='utf-8') as f:
                typo_rules = json.load(f)
        else:
            typo_rules = []
        # --- UI: таблица правил ---
        from tkinter import ttk, simpledialog, messagebox
        frame = Frame(self.typos_tab)
        frame.pack(fill="both", expand=True, padx=10, pady=5)
        columns = ("active", "orig", "suggest", "edit", "delete")
        tree = ttk.Treeview(frame, columns=columns, show="headings", height=15)
        tree.heading("active", text="Активно")
        tree.heading("orig", text="Исходное значение")
        tree.heading("suggest", text="Вариант замены")
        tree.heading("edit", text="Редактировать")
        tree.heading("delete", text="Удалить")
        tree.column("active", width=70, anchor='center')
        tree.column("orig", width=220)
        tree.column("suggest", width=220)
        tree.column("edit", width=100, anchor='center')
        tree.column("delete", width=100, anchor='center')
        tree.pack(fill="both", expand=True, side=LEFT)
        # --- Список переменных активности ---
        self.typo_rule_vars = []
        for idx, rule in enumerate(typo_rules):
            active = rule.get('active', True)
            tree.insert("", "end", iid=str(idx), values=("✔" if active else "", rule['orig'], rule['suggest'], "Редактировать", "Удалить"))
            self.typo_rule_vars.append(active)
        # --- Обработчики кликов по кнопкам ---
        def on_tree_click(event):
            item = tree.identify_row(event.y)
            col = tree.identify_column(event.x)
            if not item:
                return
            idx = int(item)
            if col == '#4':  # Редактировать
                rule = typo_rules[idx]
                new_orig = simpledialog.askstring("Редактировать исходное значение", "Новое исходное значение:", initialvalue=rule['orig'])
                if new_orig is None:
                    return
                new_suggest = simpledialog.askstring("Редактировать вариант замены", "Новый вариант замены:", initialvalue=rule['suggest'])
                if new_suggest is None:
                    return
                typo_rules[idx]['orig'] = new_orig
                typo_rules[idx]['suggest'] = new_suggest
                save_typos()
                self.setup_typos_tab()
            elif col == '#5':  # Удалить
                if messagebox.askyesno("Удалить правило", "Удалить это правило?"):
                    typo_rules.pop(idx)
                    save_typos()
                    self.setup_typos_tab()
            elif col == '#1':  # Активно (чекбокс)
                typo_rules[idx]['active'] = not typo_rules[idx].get('active', True)
                save_typos()
                self.setup_typos_tab()
        tree.bind('<Button-1>', on_tree_click)
        # --- Кнопка добавления ---
        def add_rule():
            orig = simpledialog.askstring("Новое правило", "Исходное значение:")
            if not orig:
                return
            suggest = simpledialog.askstring("Новое правило", "Вариант замены:")
            if suggest is None:
                return
            typo_rules.append({'orig': orig, 'suggest': suggest, 'active': True})
            save_typos()
            self.setup_typos_tab()
        Button(self.typos_tab, text="Добавить правило", command=add_rule).pack(pady=10)
        # --- Кнопка применения ---
        def apply_typos():
            if not hasattr(self, 'current_df') or self.current_df is None:
                return
            df = self.current_df.copy()
            for rule in typo_rules:
                if rule.get('active', True):
                    orig = rule['orig']
                    suggest = rule['suggest']
                    if orig:
                        for col in df.columns:
                            if col == 'Артикул':
                                continue
                            df[col] = df[col].astype(str).apply(lambda x: x.replace(orig, suggest))
            self.current_df = df
            self.refresh_main_table_from_df()
        Button(self.typos_tab, text="Применить выбранные", command=apply_typos).pack(pady=10)
        # --- Сохранение ---
        def save_typos():
            with open(typo_rules_file, 'w', encoding='utf-8') as f:
                json.dump(typo_rules, f, ensure_ascii=False, indent=2)

    def update_replace_col_menu(self):
        # Обновляет выпадающий список столбцов с учетом переводов параметров
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        if hasattr(self, 'replace_col_menu'):
            # Получаем переводы
            translations = getattr(self, 'param_translations', None)
            cols = [col for col in self.current_df.columns if col != 'Артикул']
            if translations:
                display_cols = [translations.get(normalize_param_name(col), col) for col in cols]
            else:
                display_cols = cols
            self.replace_col_menu['values'] = display_cols
            if display_cols:
                self.replace_col_var.set(display_cols[0])
            self.replace_col_menu.update_idletasks()

    def export_to_csv(self):
        import pandas as pd
        from tkinter import filedialog
        if not hasattr(self, 'current_df') or self.current_df is None:
            self.info_label.config(text='Нет данных для экспорта')
            return
        file = filedialog.asksaveasfilename(defaultextension='.csv', filetypes=[('CSV files', '*.csv')])
        if file:
            self.current_df.to_csv(file, index=False, encoding='utf-8-sig')
            self.info_label.config(text=f'Экспортировано в {file}')

    def save_column_history(self):
        # Сохраняет историю изменений столбцов
        data = {
            'column_history': self.column_history,
            'column_op_history': self.column_op_history,
            'column_history_active': getattr(self, 'column_history_active', {})
        }
        with open('column_history.json', 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

    def load_column_history(self):
        # Загружает историю изменений столбцов
        import os
        if os.path.exists('column_history.json'):
            with open('column_history.json', 'r', encoding='utf-8') as f:
                data = json.load(f)
            self.column_history = data.get('column_history', {})
            self.column_op_history = data.get('column_op_history', {})
            self.column_history_active = data.get('column_history_active', {})
        else:
            self.column_history = {}
            self.column_op_history = {}
            self.column_history_active = {}

def normalize_param_name(name):
    import re
    name = re.sub(r'[-_]', ' ', name)
    name = re.sub(r'\s+', ' ', name)
    name = name.strip().lower()
    return name

def normalize_value(val):
    # Привести все кавычки к обычным двойным
    val = val.replace("'", '"').replace("'", '"').replace("''", '"').replace("`", '"').replace("ʼ", '"')
    val = val.replace(""", '"').replace(""", '"').replace("«", '"').replace("»", '"')
    val = val.replace("'", '"')
    # Удалить лишние пробелы вокруг знаков препинания и внутри строки
    import re
    val = re.sub(r'\s+', ' ', val)
    val = re.sub(r'\s*([,;/])\s*', r'\1 ', val)
    val = val.strip()
    return val

def remove_duplicates_normalized(values):
    seen = set()
    result = []
    for v in values:
        norm = normalize_value(v)
        if norm not in seen:
            seen.add(norm)
            result.append(norm)
    return result

if __name__ == "__main__":
    root = tk.Tk()
    app = AttributeEditor(root)
    root.mainloop() 

Приведенный выше код некорректно обрабатывает вот такую таблицу:
<table border="0" width="616" cellspacing="0" cellpadding="0"><colgroup><col /><col /></colgroup>
<tbody>
<tr>
<td height="16">　</td>
<td>IPC2233-FN-SIR40-TU</td>
</tr>
<tr>
<td colspan="2" height="16">Camera</td>
</tr>
<tr>
<td height="16">Image Sensor&nbsp;</td>
<td>1/2.8&rsquo;&rsquo; progressive scan CMOS</td>
</tr>
<tr>
<td height="16">Pixels</td>
<td>2.0 Megapixel</td>
</tr>
<tr>
<td rowspan="2" height="96">Min. Illumination&nbsp;</td>
<td>Color: 0.005lux@ (F1.2,&nbsp; AGC ON)<br />Color: 0.0035lux@ (F1.4,&nbsp; AGC ON)<br />Color: 0.002lux@ (F1.6,&nbsp; AGC ON)</td>
</tr>
<tr>
<td height="48">B/W: 0.0005lux@ (F1.2,&nbsp; AGC ON)<br />B/W: 0.00035lux@ (F1.4,&nbsp; AGC ON)<br />B/W: 0.0002lux@ (F1.6,&nbsp; AGC ON); 0lux&nbsp; with IR</td>
</tr>
<tr>
<td height="16">Shutter Speed</td>
<td>1 ~ 1/30,000s</td>
</tr>
<tr>
<td height="16">Day / Night</td>
<td>Auto (ICR) / Color / B/W</td>
</tr>
<tr>
<td height="16">Wide Dynamic Range</td>
<td>Ultra WDR, &gt;=120dB</td>
</tr>
<tr>
<td height="16">S/N Ratio</td>
<td>&gt;50dB</td>
</tr>
<tr>
<td height="16">White Balance</td>
<td>Auto / Manual</td>
</tr>
<tr>
<td height="16">Gain Control</td>
<td>Auto / Manual&nbsp;</td>
</tr>
<tr>
<td height="16">Backlight Compensation</td>
<td>Off / BLC / WDR / HLC</td>
</tr>
<tr>
<td colspan="2" height="16">Lens</td>
</tr>
<tr>
<td height="16">Mount Type</td>
<td>&Phi;14</td>
</tr>
<tr>
<td height="16">Auto Iris</td>
<td>DC drive</td>
</tr>
<tr>
<td height="16">Focal Length</td>
<td>-Z2712: 2.7~12mm motorized varifocal lens</td>
</tr>
<tr>
<td height="16">Focus Control</td>
<td>Manual / Semi-Automatic / Auto</td>
</tr>
<tr>
<td height="16">Angle of View</td>
<td>-Z2712: 106&deg; ~ 33&deg;</td>
</tr>
<tr>
<td height="16">Adjustment Range</td>
<td>Pan: -120&deg; ~ +120&deg; / Tilt: 0&deg; ~ 65&deg; / Rotation: -90&deg; ~ +90&deg;</td>
</tr>
<tr>
<td colspan="2" height="16">Infrared</td>
</tr>
<tr>
<td height="16">IR Distance</td>
<td>40m</td>
</tr>
<tr>
<td height="16">IR Intensity</td>
<td>Smart IR</td>
</tr>
<tr>
<td colspan="2" height="16">Video</td>
</tr>
<tr>
<td height="16">Compression</td>
<td>H265 + / H.265 / H.264 / MJPEG</td>
</tr>
<tr>
<td rowspan="3" height="48">Image Resolution</td>
<td>Main stream: 1080p</td>
</tr>
<tr>
<td height="16">Secondary stream: D1</td>
</tr>
<tr>
<td height="16">Triple stream: 1280 x 720@30fps</td>
</tr>
<tr>
<td rowspan="2" height="32">Frame Rate</td>
<td>Main stream: 1080p@30fps</td>
</tr>
<tr>
<td height="16">Secondary stream: D1@30fps</td>
</tr>
<tr>
<td height="16">Bitrate</td>
<td>32kbps ~ 16Mbps</td>
</tr>
<tr>
<td height="16">Bitrate Control</td>
<td>VBR / CBR</td>
</tr>
<tr>
<td height="16">Multi-stream</td>
<td>Supported</td>
</tr>
<tr>
<td height="16">Caption</td>
<td>Date and time / Alarm / Customized / Min. 50 characters</td>
</tr>
<tr>
<td height="16">Privacy Masking</td>
<td>Up to 4 zones</td>
</tr>
<tr>
<td height="16">Motion Detection</td>
<td>Up to 4 zones</td>
</tr>
<tr>
<td height="16">Video Freeze</td>
<td>Supported</td>
</tr>
<tr>
<td height="16">ROI</td>
<td>Supported</td>
</tr>
<tr>
<td height="16">Digital Noise Reduction</td>
<td>2DNR, 3DNR</td>
</tr>
<tr>
<td height="16">Digital Defog</td>
<td>Supported</td>
</tr>
<tr>
<td height="16">Image Flip</td>
<td>Flip / Mirror / Rotation at 90&deg;,180&deg;,270&deg;</td>
</tr>
<tr>
<td height="16">Corider Mode</td>
<td>Supported</td>
</tr>
<tr>
<td height="16">EIS</td>
<td>Supported</td>
</tr>
<tr>
<td height="16">Distortion Connection</td>
<td>Supported</td>
</tr>
<tr>
<td colspan="2" height="16">Audio</td>
</tr>
<tr>
<td height="16">Compression</td>
<td>G.711a / G.711u / ADPCM / G.722 / G.722.1c / G.726</td>
</tr>
<tr>
<td height="16">Bitrate</td>
<td>8kbps ~ 64kbps</td>
</tr>
<tr>
<td height="16">Audio Function</td>
<td>Bi-directional audio / AEC / Mixed audio recording / Dumb / Mute</td>
</tr>
<tr>
<td colspan="2" height="16">Network</td>
</tr>
<tr>
<td height="48">Network Protocols</td>
<td>TCP/IP, UDP, HTTP, HTTPS, DHCP, DNS/DDNS, RTP/RTCP, RTSP, PPPoE, FTP, VSIP, UPnP, 802.1x, NAT, QoS, SSH, NTP, SNMP, IGMP, SMTP, Unicast, Multicast, IPv4, IPv6 (optional)</td>
</tr>
<tr>
<td height="16">Unicast</td>
<td>Min. 5 Users</td>
</tr>
<tr>
<td height="16">Multicast</td>
<td>Unlimited Users</td>
</tr>
<tr>
<td height="16">User Interface</td>
<td>IPC Control (IE, Firefox, Chrome)</td>
</tr>
<tr>
<td height="16">Max. User Access</td>
<td>Max. 10 Users (includes 2 internal)</td>
</tr>
<tr>
<td height="16">Application Programming</td>
<td>ONVIF (Profile S, Profile G), GB/T 28181-2011, API, CGI</td>
</tr>
<tr>
<td height="32">Automatic Network Replenishment (ANR)</td>
<td>Supported</td>
</tr>
<tr>
<td height="16">Dual IP</td>
<td>Supported</td>
</tr>
<tr>
<td height="19">Network Test</td>
<td>Supported</td>
</tr>
<tr>
<td colspan="2" height="16">Alarm</td>
</tr>
<tr>
<td height="16">Alarm Triggers</td>
<td>Alarm input / Network disconnect / Disk full / Disk error</td>
</tr>
<tr>
<td height="32">Intelligent</td>
<td>Motion detection / Tampering / Guard line / Defocus / Scene change / Enter guard area / Exit guard area / Object left / Object removal / Gathering / Audio surge</td>
</tr>
<tr>
<td height="32">Alarm Events</td>
<td>Alarm report / Recording / Text overlay / Snapshot / External output / Email notice / Acoustic alarm</td>
</tr>
<tr>
<td colspan="2" height="16">Interfaces</td>
</tr>
<tr>
<td height="16">Ethernet</td>
<td>10/100M, RJ45 interface</td>
</tr>
<tr>
<td height="16">Audio In / Out</td>
<td>2 x Line in / 1 x Line out</td>
</tr>
<tr>
<td height="16">Control</td>
<td>1 x RS485</td>
</tr>
<tr>
<td height="16">Alarm In / Out</td>
<td>1 x Input / 1 x Output</td>
</tr>
<tr>
<td height="16">Video Out</td>
<td>1 x BNC, 1.0V [p-p] / 75&Omega;</td>
</tr>
<tr>
<td height="16">Memory Slot</td>
<td>Micro SD card slot, supports up to 128GB</td>
</tr>
<tr>
<td colspan="2" height="16">Environmental</td>
</tr>
<tr>
<td height="16">Operating Temperature</td>
<td>-40&deg;C ~ 70&deg;C / -40&deg;F ~ 158&deg;F</td>
</tr>
<tr>
<td height="16">Operating Humidity&nbsp;</td>
<td>10% ~ 95%</td>
</tr>
<tr>
<td height="16">Protection Level</td>
<td>IK10+ @50J, IP67</td>
</tr>
<tr>
<td colspan="2" height="16">Electrical</td>
</tr>
<tr>
<td height="16">Power&nbsp;</td>
<td>DC12V &plusmn; 10% / PoE (IEEE802.3af)</td>
</tr>
<tr>
<td height="16">Power Consumption</td>
<td>Max. 11W</td>
</tr>
<tr>
<td colspan="2" height="16">Mechanical</td>
</tr>
<tr>
<td height="16">Weight&nbsp;</td>
<td>0.71 kg / 1.57lb (Gross Weight)</td>
</tr>
<tr>
<td height="16">Dimensions</td>
<td>&Phi;122&times; 96.13mm / &Phi;4.8&rsquo;&rsquo; x 3.78&rsquo;&rsquo;</td>
</tr>
<tr>
<td height="16">Standard</td>
<td>CE, FCC, EMC, LVD</td>
</tr>
</tbody>
</table>

Мы видим, что в ней есть строка с параметром "Min. Illumination&nbsp;".
Но в значениях данного параметра мы видим 2 строки, в каждой из которых есть значения, которые расположены друг под другом:
Color: 0.005lux@ (F1.2,  AGC ON)
Color: 0.0035lux@ (F1.4,  AGC ON)
Color: 0.002lux@ (F1.6,  AGC ON)
и в  другой строке:
B/W: 0.0005lux@ (F1.2,  AGC ON)
B/W: 0.00035lux@ (F1.4,  AGC ON)
B/W: 0.0002lux@ (F1.6,  AGC ON); 0lux  with IR

В настоящий момент все эти значения выгружаются, но запятой разделяются только значения из разных строк, а те, которые расположены в одной ячейке, например,
Color: 0.005lux@ (F1.2,  AGC ON)
Color: 0.0035lux@ (F1.4,  AGC ON)
Color: 0.002lux@ (F1.6,  AGC ON)
и
B/W: 0.0005lux@ (F1.2,  AGC ON)
B/W: 0.00035lux@ (F1.4,  AGC ON)
B/W: 0.0002lux@ (F1.6,  AGC ON); 0lux  with IR
запятыми не разделяются, а выгружаются вот так:
Color: 0.005lux@ (F1.2,  AGC ON)Color: 0.0035lux@ (F1.4,  AGC ON)Color: 0.002lux@ (F1.6,  AGC ON), B/W: 0.0005lux@ (F1.2,  AGC ON)B/W: 0.00035lux@ (F1.4,  AGC ON)B/W: 0.0002lux@ (F1.6,  AGC ON); 0lux  with IR

а должно выгружаться вот так:
Color: 0.005lux@ (F1.2,  AGC ON), Color: 0.0035lux@ (F1.4,  AGC ON), Color: 0.002lux@ (F1.6,  AGC ON), B/W: 0.0005lux@ (F1.2,  AGC ON), B/W: 0.00035lux@ (F1.4,  AGC ON), B/W: 0.0002lux@ (F1.6,  AGC ON); 0lux  with IR

Внеси необходимые правки, но не испорть то, что уже сейчас работает корректно.