Вот код моего приложения:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pandas as pd
import re
from functools import partial

def extract_price(text):
    if not isinstance(text, str):
        return ""
    m = re.search(r'(\d{1,3}(?:\s?\d{3})*)\s*руб\.', str(text).replace('\xa0', ' '))
    return m.group(1).replace(' ', '') if m else ""

def process_row(clean_lines, checked_top, checked_bottom):
    k_top = min(sum(checked_top), len(clean_lines))
    k_bottom = min(sum(checked_bottom), max(0, len(clean_lines) - k_top))
    name = " ".join(clean_lines[:k_top]) if k_top > 0 else ""
    desc_lines = clean_lines[k_top:len(clean_lines)-k_bottom if k_bottom > 0 else None]
    desc = "\n".join(desc_lines)
    return name, desc

def clean_lines_from_text(desc_raw):
    lines = [re.sub(r'\s+', ' ', l.strip().replace('\xa0', ' ')) for l in str(desc_raw).splitlines() if l.strip()]
    return lines

def calculate_price_excl_vat(price_str):
    try:
        price_float = float(price_str.replace(' ', '').replace(',', '.'))
        price_wo_vat = round(price_float / 1.2, 2)
        return str(price_wo_vat).replace('.', ',')
    except Exception:
        return ""

class TableWithCheckboxes(tk.Frame):
    def __init__(self, master, headers, *args, **kwargs):
        super().__init__(master, *args, **kwargs)
        self.headers = headers
        self.rows_widgets = []

        self.col_widths = [
            max(len(h), 12) if idx < 4 else max(len(h), 18)
            for idx, h in enumerate(headers)
        ]

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

        container = tk.Frame(self)
        container.grid(row=0, column=0, sticky='nsew')
        container.grid_rowconfigure(0, weight=1)
        container.grid_columnconfigure(0, weight=1)

        self.canvas = tk.Canvas(container, borderwidth=0, highlightthickness=0)
        self.v_scroll = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview)
        self.h_scroll = ttk.Scrollbar(container, orient='horizontal', command=self.canvas.xview)
        self.canvas.configure(yscrollcommand=self.v_scroll.set, xscrollcommand=self.h_scroll.set)

        self.canvas.grid(row=0, column=0, sticky='nsew')
        self.v_scroll.grid(row=0, column=1, sticky='ns')
        self.h_scroll.grid(row=1, column=0, sticky='ew')

        self.table_frame = tk.Frame(self.canvas)
        self.canvas_frame_id = self.canvas.create_window((0, 0), window=self.table_frame, anchor="nw")

        self.table_frame.bind("<Configure>", self._on_frame_configure)
        self.canvas.bind("<Configure>", self._on_canvas_configure)

        self.header_labels = []
        
        # Добавленные переменные для чекбоксов "Выбрать все"
        self.select_all_top_vars = [tk.IntVar(value=1) for _ in range(3)]
        self.select_all_bottom_vars = [tk.IntVar(value=1) for _ in range(3)]

        self._make_header()

        for i, width in enumerate(self.col_widths):
            self.table_frame.grid_columnconfigure(i, weight=1, minsize=width * 7)

        self.editing_widgets = dict()

    def _make_header(self):
        # Удаляем старые виджеты заголовков
        for lbl in self.header_labels:
            lbl.destroy()
        self.header_labels.clear()

        for i, hdr in enumerate(self.headers):
            wrap_len_px = self.col_widths[i] * 7

            # Для столбцов 5 и 6 — делаем рамку с чекбоксами
            if i == 5:
                frame = tk.Frame(self.table_frame, relief="ridge", borderwidth=2, bg="#dde7f3")
                frame.grid(row=0, column=i, sticky="nsew", padx=2, pady=2)
                for j in range(3):
                    cb = tk.Checkbutton(frame,
                                        variable=self.select_all_top_vars[j],
                                        text=str(j + 1),
                                        bg="#dde7f3",
                                        font=('Arial', 11, 'bold'),
                                        command=lambda idx=j: self._toggle_all_checkboxes(column=5, idx_in_cb=idx, is_top=True))
                    cb.pack(side='left', padx=4, pady=5)
                self.header_labels.append(frame)

            elif i == 6:
                frame = tk.Frame(self.table_frame, relief="ridge", borderwidth=2, bg="#dde7f3")
                frame.grid(row=0, column=i, sticky="nsew", padx=2, pady=2)
                for j in range(3):
                    cb = tk.Checkbutton(frame,
                                        variable=self.select_all_bottom_vars[j],
                                        text=str(j + 1),
                                        bg="#dde7f3",
                                        font=('Arial', 11, 'bold'),
                                        command=lambda idx=j: self._toggle_all_checkboxes(column=6, idx_in_cb=idx, is_top=False))
                    cb.pack(side='left', padx=4, pady=5)
                self.header_labels.append(frame)

            else:
                lbl = tk.Label(
                    self.table_frame, text=hdr, relief="ridge", borderwidth=2,
                    font=('Arial', 11, 'bold'), width=self.col_widths[i], height=2,
                    anchor="center", bg="#dde7f3", wraplength=wrap_len_px, justify='center')
                lbl.grid(row=0, column=i, sticky="nsew", padx=2, pady=2)
                self.header_labels.append(lbl)

            self.table_frame.grid_columnconfigure(i, weight=1, minsize=self.col_widths[i] * 7)

        self.after(10, self._update_scrollregion)

    def _toggle_all_checkboxes(self, column, idx_in_cb, is_top):
        if is_top:
            new_val = self.select_all_top_vars[idx_in_cb].get()
        else:
            new_val = self.select_all_bottom_vars[idx_in_cb].get()

        for row_widgets in self.rows_widgets:
            try:
                # В rows_widgets хранятся (для кнопок):
                # base колонки (0..4) + cb_frame_top(5), cb_frame_bottom(6), 
                # а также далее vars_top, vars_bottom (добавлены в insert_row)
                vars_top = row_widgets[-2]
                vars_bottom = row_widgets[-1]
                if is_top:
                    if vars_top[idx_in_cb].get() != new_val:
                        vars_top[idx_in_cb].set(new_val)
                else:
                    if vars_bottom[idx_in_cb].get() != new_val:
                        vars_bottom[idx_in_cb].set(new_val)
            except Exception:
                pass  # не страшно

    def _on_frame_configure(self, event):
        self._update_scrollregion()

    def _on_canvas_configure(self, event=None):
        canvas_width = self.canvas.winfo_width()
        total_width = sum(max(w * 7, 50) for w in self.col_widths)
        new_width = max(canvas_width, total_width)
        self.canvas.itemconfig(self.canvas_frame_id, width=new_width)
        self._update_scrollregion()

    def _update_scrollregion(self):
        bbox = self.canvas.bbox("all")
        if bbox:
            self.canvas.configure(scrollregion=bbox)

    def clear_table(self):
        for widgets in self.rows_widgets:
            for w in widgets:
                if isinstance(w, list):
                    for ww in w:
                        ww = None  # vars_top/vars_bottom - ничего удалять не надо
                elif w is not None:
                    w.destroy()
        self.rows_widgets.clear()
        self.editing_widgets.clear()
        self.canvas.yview_moveto(0)
        self.canvas.xview_moveto(0)
        self._update_scrollregion()

    def update_column_widths(self, texts, col_idx):
        current_max_len = self.col_widths[col_idx]
        max_text_len = max((len(str(t)) for t in texts), default=0)
        header_len = len(self.headers[col_idx])

        base_min_width = 18 if col_idx >= 4 else 12

        new_width = max(current_max_len, max_text_len, header_len, base_min_width)

        if new_width > self.col_widths[col_idx]:
            self.col_widths[col_idx] = new_width
            self._make_header()

            for row_widgets in self.rows_widgets:
                if row_widgets and len(row_widgets) > col_idx:
                    if col_idx <= 4:
                        row_widgets[col_idx].config(width=self.col_widths[col_idx])
                    else:
                        row_widgets[col_idx].config(width=self.col_widths[col_idx] * 7)
            self._update_scrollregion()

    def insert_row(self, row_idx, art, name, descr, price, price_wo_vat, vars_top, vars_bottom, additional_values, visible_cols, all_dynamic_cols, callback):
        row = []
        desc_lines = descr.split('\n')
        n_lines = max(1, len(desc_lines))
        font = ('Arial', 10)
        bg = "#f9fafd" if row_idx % 2 == 0 else "#f2f6fb"

        def make_label(text, col):
            if col == 1:  # Наименование
                lbl = tk.Label(self.table_frame, text=text, relief='ridge', borderwidth=1,
                               font=font, height=n_lines, anchor='w', justify='left',
                               bg=bg, wraplength=self.col_widths[col]*7)
            else:
                w = self.col_widths[col]
                lbl = tk.Label(self.table_frame, text=text, relief='ridge', borderwidth=1,
                               width=w, height=n_lines, font=font,
                               anchor='w' if col in [0,2] else 'center',
                               justify='left' if col in [0,1,2] else 'center',
                               bg=bg)
            lbl.grid(row=row_idx + 1, column=col, sticky='nsew')
            lbl.bind('<Double-1>', partial(self._start_edit, row_idx, col))
            return lbl

        # Выводим базовые колонки (0..4) всегда
        art_lbl = make_label(art, 0)
        name_lbl = make_label(name, 1)
        descr_lbl = make_label(descr, 2)
        price_lbl = make_label(price, 3)
        price_wo_vat_lbl = make_label(price_wo_vat, 4)

        row.extend([art_lbl, name_lbl, descr_lbl, price_lbl, price_wo_vat_lbl])

        cb_frame_top = tk.Frame(self.table_frame, relief='ridge', borderwidth=1, bg=bg)
        cb_frame_top.grid(row=row_idx + 1, column=5, sticky="nsew", padx=2, pady=2)
        row.append(cb_frame_top)
        row_vars_top = []
        for i in range(3):
            cb = tk.Checkbutton(cb_frame_top, variable=vars_top[i], text=str(i + 1), command=callback, bg=bg)
            cb.pack(side='left', padx=5, pady=n_lines * 6 // 3)
            row_vars_top.append(vars_top[i])

        cb_frame_bottom = tk.Frame(self.table_frame, relief='ridge', borderwidth=1, bg=bg)
        cb_frame_bottom.grid(row=row_idx + 1, column=6, sticky="nsew", padx=2, pady=2)
        row.append(cb_frame_bottom)
        row_vars_bottom = []
        for i in range(3):
            cb = tk.Checkbutton(cb_frame_bottom, variable=vars_bottom[i], text=str(i + 1), command=callback, bg=bg)
            cb.pack(side='left', padx=5, pady=n_lines * 6 // 3)
            row_vars_bottom.append(vars_bottom[i])

        # Начинаем индекс колонок дополнительных с 7
        start_col = 7

        # Для всех колонок из полного списка динамических доп. (file+static)
        # Показываем только те, что есть в visible_cols (имена)
        for idx, (typ, colname) in enumerate(all_dynamic_cols):
            if colname not in visible_cols:
                continue  # Пропущена колонка, не показываем

            # Получаем значение (file или static)
            value = additional_values[idx]
            col_idx = start_col + idx
            lbl = tk.Label(self.table_frame, text=str(value), relief='ridge', borderwidth=1,
                           width=self.col_widths[col_idx], height=n_lines, font=font,
                           anchor='w', justify='left', bg=bg)
            lbl.grid(row=row_idx + 1, column=col_idx, sticky='nsew')
            lbl.bind('<Double-1>', partial(self._start_edit, row_idx, col_idx))
            row.append(lbl)

        # Добавляем переменные для удобного доступа (нужно для toggle_all_checkboxes)
        row.append(row_vars_top)    # индекс -2
        row.append(row_vars_bottom) # индекс -1

        self.rows_widgets.append(row)
        self._update_scrollregion()

    def _start_edit(self, row_idx, col_idx, event):
        key = (row_idx, col_idx)
        if key in self.editing_widgets:
            return

        label = self.rows_widgets[row_idx][col_idx]
        x = label.winfo_x()
        y = label.winfo_y()
        width = label.winfo_width()
        height = label.winfo_height()

        entry = tk.Text(self.table_frame, font=label.cget("font"), wrap='word', undo=True)
        entry.place(x=x, y=y, width=width, height=height)
        current_text = label.cget("text")
        entry.insert('1.0', current_text)
        entry.focus_set()
        self.editing_widgets[key] = entry

        def save_edit(event=None):
            new_text = entry.get('1.0', 'end-1c')
            label.config(text=new_text)
            entry.destroy()
            del self.editing_widgets[key]
            if hasattr(self.master, 'update_row'):
                self.master.update_row(row_idx)

        def cancel_edit(event=None):
            entry.destroy()
            if key in self.editing_widgets:
                del self.editing_widgets[key]

        entry.bind("<FocusOut>", save_edit)
        entry.bind("<Return>", lambda e: (save_edit(), "break"))
        entry.bind("<Escape>", lambda e: (cancel_edit(), "break"))


class DynamicTableApp:
    def __init__(self, master):
        self.master = master

        self.master.title("Milesight: Таблица с динамическими чекбоксами")

        self.df = None
        self.rowdata = []

        self.base_headers = [
            'Артикул', 'Наименование', 'Описание', 'РРЦ', 'РРЦ (без НДС)',
            'Удалить первые строки', 'Удалить последние строки'
        ]

        self.additional_headers = []
        self.additional_columns_data = []

        self.static_headers = []
        self.static_columns_data = []

        self.additional_columns_data_extended = []
        self.visible_dynamic_headers = []

        self.headers = self.base_headers.copy()

        controls_frame = ttk.Frame(master)
        controls_frame.pack(fill='x', pady=6, padx=10)

        btn_load = ttk.Button(controls_frame, text="Загрузить Excel", command=self.load_file, width=16)
        btn_load.grid(row=0, column=0, padx=(0, 8), sticky='w')

        ttk.Label(controls_frame, text="Артикул:").grid(row=0, column=1, sticky='e')
        self.art_var = tk.StringVar()
        self.art_cb = ttk.Combobox(controls_frame, textvariable=self.art_var, width=18)
        self.art_cb.grid(row=0, column=2, padx=5)
        self.art_cb.bind("<<ComboboxSelected>>", self.refresh_additional_checkboxes)

        ttk.Label(controls_frame, text="Описание:").grid(row=0, column=3, sticky='e', padx=(15,0))
        self.desc_var = tk.StringVar()
        self.desc_cb = ttk.Combobox(controls_frame, textvariable=self.desc_var, width=18)
        self.desc_cb.grid(row=0, column=4, padx=5)
        self.desc_cb.bind("<<ComboboxSelected>>", self.refresh_additional_checkboxes)

        ttk.Label(controls_frame, text="Фильтр:").grid(row=0, column=5, sticky='e', padx=(15,0))
        self.filter_text_var = tk.StringVar(value="Модель снята с производства")
        ttk.Entry(controls_frame, textvariable=self.filter_text_var, width=30).grid(row=0, column=6, padx=5)

        btn_process = ttk.Button(controls_frame, text="Показать таблицу", command=self.process, width=15)
        btn_process.grid(row=0, column=7, padx=(10,5))
        self.save_btn = ttk.Button(controls_frame, text="Сохранить", command=self.save, state='disabled', width=15)
        self.save_btn.grid(row=0, column=8)

        addcols_frame = ttk.LabelFrame(master, text="Дополнительные столбцы (показывать / скрывать)")
        addcols_frame.pack(fill='x', pady=4, padx=10)

        self.addcols_canvas = tk.Canvas(addcols_frame, height=80)
        self.addcols_canvas.pack(side='top', fill='x', expand=True)
        self.addcols_h_scroll = ttk.Scrollbar(addcols_frame, orient='horizontal', command=self.addcols_canvas.xview)
        self.addcols_h_scroll.pack(side='bottom', fill='x')
        self.addcols_canvas.configure(xscrollcommand=self.addcols_h_scroll.set)

        self.addcols_inner_frame = tk.Frame(self.addcols_canvas)
        self.addcols_canvas.create_window((0, 0), window=self.addcols_inner_frame, anchor='nw')
        self.addcols_inner_frame.bind("<Configure>",
            lambda e: self.addcols_canvas.configure(scrollregion=self.addcols_canvas.bbox("all")))

        self.column_visible_vars = dict()

        static_frame = ttk.LabelFrame(master, text="Добавить статичный столбец")
        static_frame.pack(fill='x', padx=10, pady=(4,10))

        ttk.Label(static_frame, text="Имя:").pack(side='left', padx=5)
        self.static_col_name_var = tk.StringVar()
        ttk.Entry(static_frame, textvariable=self.static_col_name_var, width=15).pack(side='left')

        ttk.Label(static_frame, text="Значение:").pack(side='left', padx=5)
        self.static_col_value_var = tk.StringVar()
        ttk.Entry(static_frame, textvariable=self.static_col_value_var, width=25).pack(side='left')

        ttk.Button(static_frame, text="Добавить", command=self.add_static_column).pack(side='left', padx=10)

        self.master.after(100, self._maximize_window)

        self.table = TableWithCheckboxes(master, self.headers)
        self.table.pack(fill='both', expand=True, padx=10, pady=5)

    def _maximize_window(self):
        try:
            self.master.state('zoomed')
        except:
            try:
                self.master.attributes('-zoomed', True)
            except:
                self.master.attributes('-fullscreen', True)

    def load_file(self):
        filename = filedialog.askopenfilename(filetypes=[("Excel files", "*.xlsx *.xls"), ("Все файлы", "*.*")])
        if not filename:
            return
        self.df = pd.read_excel(filename)
        cols = list(self.df.columns)
        self.art_cb['values'] = cols
        self.desc_cb['values'] = cols

        self.art_var.set('Text2' if 'Text2' in cols else (cols[0] if cols else ''))
        self.desc_var.set('Text3' if 'Text3' in cols else (cols[-1] if cols else ''))

        self.additional_headers.clear()
        self.additional_columns_data.clear()
        self.static_headers.clear()
        self.static_columns_data.clear()
        self.additional_columns_data_extended.clear()
        self.visible_dynamic_headers.clear()

        self.headers = self.base_headers.copy()
        self.table.headers = self.headers
        self.table.clear_table()
        self.table._make_header()
        self.save_btn.config(state='disabled')

        self.refresh_additional_checkboxes()
        self.refresh_column_visibility_checkboxes()
        messagebox.showinfo("Готово", "Файл успешно загружен")

    def refresh_additional_checkboxes(self, event=None):
        # При изменении Art/Desc обновляем доступные колонки
        if self.df is None:
            return
        art_col = self.art_var.get()
        desc_col = self.desc_var.get()
        excluded = {art_col, desc_col}
        available_cols = [c for c in self.df.columns if c not in excluded]

        # Обновим списки доп и данные
        self.additional_headers = available_cols
        self.additional_columns_data = [('file_column', c) for c in available_cols]

        self.update_headers_and_redraw()
        # Обновить чекбоксы видимости
        self.refresh_column_visibility_checkboxes()

    def refresh_column_visibility_checkboxes(self):
        for w in self.addcols_inner_frame.winfo_children():
            w.destroy()
        self.column_visible_vars.clear()

        # Все динамические колонки - доп из файла + статичные
        self.all_dynamic_columns = []
        self.all_dynamic_columns.extend([('file', c) for c in self.additional_headers])
        self.all_dynamic_columns.extend([('static', c) for c in self.static_headers])

        # Отметим все ранее видимые, если есть, иначе все отображать по-умолчанию
        visible_default = set(self.visible_dynamic_headers) if self.visible_dynamic_headers else set(c for _, c in self.all_dynamic_columns)

        for typ, colname in self.all_dynamic_columns:
            var = tk.IntVar(value=1 if colname in visible_default else 0)
            cb = tk.Checkbutton(self.addcols_inner_frame, text=colname, variable=var, command=self.update_visible_columns)
            cb.pack(side='left', padx=6, pady=4)
            self.column_visible_vars[colname] = var

        self.visible_dynamic_headers = [c for c, v in self.column_visible_vars.items() if v.get() == 1]
        self.addcols_canvas.xview_moveto(0)

    def update_visible_columns(self):
        # Обновляем список видимых колонок исходя из чекбоксов
        self.visible_dynamic_headers = [col for col, var in self.column_visible_vars.items() if var.get() == 1]
        self.headers = self.base_headers + self.visible_dynamic_headers
        self.table.headers = self.headers
        self.table.col_widths = [
            max(len(h), 12) if idx < 4 else max(len(h), 18)
            for idx, h in enumerate(self.headers)
        ]
        self.redraw_table()
        self.force_update_canvas()

    def add_static_column(self):
        name = self.static_col_name_var.get().strip()
        value = self.static_col_value_var.get()
        if not name:
            messagebox.showerror("Ошибка", "Имя столбца не может быть пустым")
            return
        # Проверяем уникальность среди базовых, доп и статичных
        if name in self.base_headers or name in self.additional_headers or name in self.static_headers:
            messagebox.showerror("Ошибка", "Столбец с таким именем уже есть")
            return
        self.static_headers.append(name)
        self.static_columns_data.append(('static', value))
        self.static_col_name_var.set('')
        self.static_col_value_var.set('')
        self.update_headers_and_redraw()
        self.refresh_column_visibility_checkboxes()
        messagebox.showinfo("Готово", f"Статичный столбец '{name}' добавлен")

    def update_headers_and_redraw(self):
        self.additional_columns_data_extended = self.additional_columns_data + self.static_columns_data
        # Учитываем, что при отрисовке показываем только видимые
        # visible_dynamic_headers должны содержать только те, что показываются
        #visible_dynamic_headers формируется отдельно
        self.headers = self.base_headers + self.visible_dynamic_headers
        self.table.headers = self.headers
        self.table.col_widths = [
            max(len(h), 12) if idx < 4 else max(len(h), 18)
            for idx, h in enumerate(self.headers)
        ]
        self.redraw_table()
        self.force_update_canvas()

    def force_update_canvas(self):
        def _update():
            self.master.update_idletasks()
            canvas = self.table.canvas
            total_width = sum(max(w * 7, 50) for w in self.table.col_widths)
            canvas_width = canvas.winfo_width()
            new_width = max(canvas_width, total_width)
            canvas.itemconfig(self.table.canvas_frame_id, width=new_width)
            canvas.configure(scrollregion=canvas.bbox("all"))
            event = tk.Event()
            event.width = canvas_width
            self.table._on_canvas_configure(event)
            self.master.update_idletasks()
        self.master.after(50, _update)

    def redraw_table(self):
        if self.df is None:
            return
        filter_text = self.filter_text_var.get().strip()

        self.table.clear_table()
        self.rowdata.clear()

        art_col = self.art_var.get()
        desc_col = self.desc_var.get()

        idx_for_rowdata = 0
        # Сформируем список all динамических колонок (file+static),
        # для удобства при отрисовке
        all_dynamic_cols = []
        all_dynamic_cols.extend([('file_column', c) for c in self.additional_headers])
        all_dynamic_cols.extend([('static', c) for c in self.static_headers])

        for i, row in enumerate(self.df.itertuples()):
            desc_raw = getattr(row, desc_col)
            if filter_text and isinstance(desc_raw, str) and filter_text in desc_raw:
                continue
            art = getattr(row, art_col) if hasattr(row, art_col) else ""
            price_str = extract_price(desc_raw)
            price_wo_vat_str = calculate_price_excl_vat(price_str)
            lines = clean_lines_from_text(desc_raw)

            vars_top = [tk.IntVar(value=1 if j < 2 else 0) for j in range(3)]
            vars_bottom = [tk.IntVar(value=1) for _ in range(3)]

            callback = partial(self.update_row, idx_for_rowdata)
            for v in vars_top + vars_bottom:
                v.trace_add('write', callback)

            name, descr = process_row(lines, [v.get() for v in vars_top], [v.get() for v in vars_bottom])

            # берём все доп значения по all_dynamic_cols (file и static)
            add_cols_values = []
            for (typ, val) in all_dynamic_cols:
                if typ == 'file_column':
                    cell_value = getattr(row, val) if hasattr(row, val) else ""
                    add_cols_values.append(cell_value)
                elif typ == 'static':
                    idx_static = self.static_headers.index(val)
                    add_cols_values.append(self.static_columns_data[idx_static][1])
                else:
                    add_cols_values.append("")

            self.table.update_column_widths([art], 0)
            self.table.update_column_widths([name], 1)
            self.table.update_column_widths(lines, 2)
            self.table.update_column_widths([price_str], 3)
            self.table.update_column_widths([price_wo_vat_str], 4)

            # Обновляем ширины только для видимых дополнительных
            for col_idx, (typ, colname) in enumerate(all_dynamic_cols):
                if colname in self.visible_dynamic_headers:
                    self.table.update_column_widths([str(add_cols_values[col_idx])], 
                                                   len(self.base_headers)+self.visible_dynamic_headers.index(colname))

            self.table.insert_row(idx_for_rowdata, art, name, descr, price_str, price_wo_vat_str,
                                  vars_top, vars_bottom, add_cols_values,
                                  self.visible_dynamic_headers,
                                  all_dynamic_cols,
                                  callback)

            self.rowdata.append({
                'lines': lines,
                'art': art,
                'desc_raw': desc_raw,
                'price_str': price_str,
                'price_wo_vat_str': price_wo_vat_str,
                'vars_top': vars_top,
                'vars_bottom': vars_bottom,
                'additional_values': add_cols_values,
            })
            idx_for_rowdata += 1

        self.save_btn.config(state='normal')
        self.table.canvas.update_idletasks()
        self.table.canvas.config(scrollregion=self.table.canvas.bbox("all"))

    def process(self):
        self.redraw_table()

    def update_row(self, idx, *args):
        item = self.rowdata[idx]
        lines = item['lines']
        top_state = [v.get() for v in item['vars_top']]
        bottom_state = [v.get() for v in item['vars_bottom']]
        name, descr = process_row(lines, top_state, bottom_state)

        widgets = self.table.rows_widgets[idx]
        art_lbl = widgets[0]
        name_lbl = widgets[1]
        desc_lbl = widgets[2]
        price_lbl = widgets[3]
        price_wo_vat_lbl = widgets[4]
        cbf_top = widgets[5]
        cbf_bottom = widgets[6]

        name_lbl.config(text=name)

        k_top = min(sum(top_state), len(lines))
        k_bottom = min(sum(bottom_state), max(0, len(lines) - k_top))
        descr_lines = lines[k_top:len(lines)-k_bottom if k_bottom > 0 else None]
        new_text = '\n'.join(descr_lines)
        desc_lbl.config(text=new_text)

        n_lines_desc = new_text.count('\n') + 1 if new_text else 1

        desc_lbl.config(height=n_lines_desc)
        art_lbl.config(height=n_lines_desc)
        price_lbl.config(height=n_lines_desc)
        price_wo_vat_lbl.config(height=n_lines_desc)

        pady_val = max(4, n_lines_desc * 6 // 3)
        for cb in cbf_top.winfo_children():
            cb.pack_configure(pady=pady_val)
        for cb in cbf_bottom.winfo_children():
            cb.pack_configure(pady=pady_val)

        additional_widgets = widgets[7:-2]  # весь под конец добавленные vars_top/vars_bottom исключаем
        for lbl in additional_widgets:
            lbl.config(height=n_lines_desc)

    def save(self):
        result = {
            'Артикул': [],
            'Наименование': [],
            'Описание': [],
            'РРЦ': [],
            'РРЦ (без НДС)': [],
        }
        # Сохраняем только базы + видимые динамические столбцы
        for colname in self.visible_dynamic_headers:
            result[colname] = []

        for i, item in enumerate(self.rowdata):
            lines = item['lines']
            top_state = [v.get() for v in item['vars_top']]
            bottom_state = [v.get() for v in item['vars_bottom']]
            name, desc = process_row(lines, top_state, bottom_state)

            result['Артикул'].append(item['art'])
            result['Наименование'].append(name)
            result['Описание'].append(desc)
            result['РРЦ'].append(item['price_str'])
            result['РРЦ (без НДС)'].append(item['price_wo_vat_str'])

            # Записываем значения только для видимых дополнительных
            all_dynamic_cols = []
            all_dynamic_cols.extend([('file_column', c) for c in self.additional_headers])
            all_dynamic_cols.extend([('static', c) for c in self.static_headers])

            # Для быстрого поиска:
            visible_set = set(self.visible_dynamic_headers)
            colname_to_idx = {colname: idx for idx, (_, colname) in enumerate(all_dynamic_cols)}

            for colname in self.visible_dynamic_headers:
                idx_col = colname_to_idx.get(colname)
                if idx_col is not None:
                    result[colname].append(item['additional_values'][idx_col])
                else:
                    result[colname].append("")

        dfout = pd.DataFrame(result)
        fname = filedialog.asksaveasfilename(defaultextension=".xlsx", filetypes=[("Excel files", "*.xlsx")])
        if not fname:
            return
        dfout.to_excel(fname, index=False)
        messagebox.showinfo("Готово", "Файл успешно сохранён")

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

Когда я загружаю в него исходный файл, у меня появляется столбец с ссылками на изображения. Сделай так, чтобы в GUI приложения была кнопка, нажав которую будут загружаться все изображения по всем ссылкам в указанном столбце в папку, названия файлов должны браться из столбца Артикул (то есть название файла будет артикул из той строки, в которой расположена ссылка на изображение).